Cookie consent card

Bottom-left card with equal-weight reject and accept, a manage layer with per-category switches. Persists the choice.

componentcardanyconsent
$npx shadcn@latest add @storekit/cookie-consent
cookie-consent

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/cookie-consent
$pnpm dlx shadcn@latest add @storekit/cookie-consent
$yarn dlx shadcn@latest add @storekit/cookie-consent
$bunx --bun shadcn@latest add @storekit/cookie-consent

Source

components/storekit/compliance/cookie-consent.tsx
196 lines
"use client"

import { useEffect, useId, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Cookie } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

type Sk = ReturnType<typeof useSkBlock>

/** Mounts with the card so the view event fires when it actually appears. */
function Tracked({ children }: { children: (sk: Sk) => React.ReactNode }) {
  const sk = useSkBlock("compliance", "cookie-consent")
  return <>{children(sk)}</>
}

/* Bottom-left cookie card. "Reject all" and "Accept all" carry equal weight
   on the first layer, as GDPR guidance expects; "Manage choices" reveals
   per-category switches (necessary is locked on) and a save button.
   The choice persists in localStorage under `storageKey` and the card stays
   hidden while one exists. `contained` anchors it to the nearest relative
   parent instead of the viewport. */

export type CookieChoice = { necessary: true; analytics: boolean; marketing: boolean; at: number }

const ring =
  "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-card"

function Switch({
  label,
  hint,
  checked,
  locked,
  onChange,
}: {
  label: string
  hint: string
  checked: boolean
  locked?: boolean
  onChange?: (v: boolean) => void
}) {
  const reduce = useReducedMotion()
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-disabled={locked || undefined}
      onClick={() => !locked && onChange?.(!checked)}
      className={cn("flex min-h-12 w-full items-center gap-3 rounded-sk-btn text-left", locked ? "cursor-not-allowed" : "cursor-pointer", ring)}
    >
      <span className="flex-1">
        <span className="block text-[13px] font-medium text-sk-ink">{label}</span>
        <span className="block text-xs text-sk-muted">{hint}</span>
      </span>
      <span
        aria-hidden
        className={cn(
          "relative flex h-6 w-10 shrink-0 items-center rounded-sk-chip p-0.5 transition-colors duration-200",
          checked ? "bg-sk-accent" : "bg-sk-ink/20",
          locked && "opacity-60"
        )}
      >
        <motion.span
          initial={false}
          animate={{ x: checked ? 16 : 0 }}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 34 }}
          className={cn("h-5 w-5 rounded-sk-chip shadow-[0_1px_2px_rgb(0_0_0/0.25)]", checked ? "bg-sk-accent-ink" : "bg-sk-paper")}
        />
      </span>
    </button>
  )
}

export function CookieConsent({
  storageKey = "sk-cookie-consent",
  policyHref = "/privacy",
  contained = false,
  onChange,
  className,
}: {
  storageKey?: string
  policyHref?: string
  contained?: boolean
  onChange?: (choice: CookieChoice) => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const [visible, setVisible] = useState(false)
  const [manage, setManage] = useState(false)
  const [analytics, setAnalytics] = useState(true)
  const [marketing, setMarketing] = useState(false)
  const titleId = useId()

  useEffect(() => {
    let stored = false
    try {
      stored = !!window.localStorage.getItem(storageKey)
    } catch {}
    if (stored) return
    const t = window.setTimeout(() => setVisible(true), 400)
    return () => window.clearTimeout(t)
  }, [storageKey])

  const save = (sk: Sk, a: boolean, m: boolean, label: string) => {
    sk.emit(label === "cookie_accept" ? "convert" : "interact", label)
    const choice: CookieChoice = { necessary: true, analytics: a, marketing: m, at: Date.now() }
    try {
      window.localStorage.setItem(storageKey, JSON.stringify(choice))
    } catch {}
    onChange?.(choice)
    setVisible(false)
  }

  const ease = [0.16, 1, 0.3, 1] as const

  return (
    <AnimatePresence>
      {visible ? (
        <Tracked key="cookie">
          {(sk) => (
            <motion.section
              ref={sk.ref}
              {...sk.attrs}
              role="region"
              aria-labelledby={titleId}
              initial={{ opacity: 0, y: reduce ? 0 : 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reduce ? 0 : 8, transition: { duration: 0.2 } }}
              transition={{ duration: 0.45, ease }}
              className={cn(
                contained ? "absolute" : "fixed",
                "bottom-[max(1rem,env(safe-area-inset-bottom))] left-4 right-4 z-40 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain rounded-sk-card border border-sk-line bg-sk-card p-5 shadow-[0_24px_60px_-24px_rgb(0_0_0/0.35)] sm:right-auto sm:w-[360px]",
                className
          )}
        >
          <div className="flex items-start gap-3">
            <Cookie size={18} aria-hidden className="mt-0.5 shrink-0 text-sk-muted" />
            <div>
              <h2 id={titleId} className="text-sm font-semibold text-sk-ink text-balance">Cookies on this site</h2>
              <p className="mt-1 text-[13px] leading-[1.5] text-sk-muted">
                We use cookies to keep your cart and measure what works.{" "}
                <a href={policyHref} className={cn("rounded-sk-btn text-sk-ink underline underline-offset-4", ring)}>Policy</a>
              </p>
            </div>
          </div>

          <AnimatePresence initial={false}>
            {manage ? (
              <motion.div
                key="manage"
                initial={{ opacity: 0, y: reduce ? 0 : 6 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, transition: { duration: 0.12 } }}
                transition={{ duration: 0.3, ease }}
                className="mt-3 divide-y divide-sk-line border-y border-sk-line"
              >
                <Switch label="Necessary" hint="Cart, checkout, security" checked locked />
                <Switch label="Analytics" hint="Anonymous usage stats" checked={analytics} onChange={setAnalytics} />
                <Switch label="Marketing" hint="Personalised offers" checked={marketing} onChange={setMarketing} />
              </motion.div>
            ) : null}
          </AnimatePresence>

          <div className="mt-4 grid grid-cols-2 gap-2">
            {manage ? (
              <>
                <Button variant="outline" size="sm" onClick={() => save(sk, false, false, "cookie_reject")}>Reject all</Button>
                <Button size="sm" onClick={() => save(sk, analytics, marketing, "cookie_save")}>Save choices</Button>
              </>
            ) : (
              <>
                <Button variant="outline" size="sm" onClick={() => save(sk, false, false, "cookie_reject")}>Reject all</Button>
                <Button size="sm" onClick={() => save(sk, true, true, "cookie_accept")}>Accept all</Button>
              </>
            )}
          </div>
          {manage ? null : (
            <button
              type="button"
              aria-expanded={false}
              onClick={() => setManage(true)}
              className={cn("mt-1 flex h-10 w-full items-center justify-center rounded-sk-btn text-[13px] font-medium text-sk-muted transition-colors duration-200 hover:text-sk-ink", ring)}
            >
              Manage choices
            </button>
          )}
        </motion.section>
          )}
        </Tracked>
      ) : null}
    </AnimatePresence>
  )
}

Usage

Read the stored choice before loading analytics or marketing scripts.