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-consentcookie-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-consentSource
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.