Cookie Consent
A complete cookie-consent kit in three styles — a full-width bar, a corner box, and an inline preferences panel — with per-category toggles and localStorage persistence.
A complete cookie-consent kit demoed in three styles — a full-width bottom bar (v1), a compact
corner box (v2), and an inline advanced preferences panel (v3) — with per-category toggles,
expandable cookie tables, a "Manage preferences" modal, and localStorage persistence. Routes at
/cookies/v1, /cookies/v2, and /cookies/v3.
Overview
The consent kit models four cookie categories — Necessary (locked, always on), Analytics,
Preferences, and Marketing — each carrying a sample cookie list (name · provider · duration)
that renders as an expandable table, exactly like a production consent tool. The visitor's choice
(accept all / decline all / a custom per-category mix) persists to localStorage and drives a
status line (pending / accepted / declined / custom).
All state and actions live in the useConsent view-model hook; the presentational pieces
(CookieBanner, CookiePreferences, CookieCategories) just take the hook's return value. The
three demo pages are thin compositions of those pieces:
- v1 / v2 (
CookieBannerDemoPage) — the same page withvariant="bar"orvariant="box", showing the fixed banner plus a status panel with "Show banner again" and "Manage cookies" actions. - v3 (
CookiesV3Page) — no banner; the granular category controls render inline on the page with twoAnimatedNumberstat tiles (total cookies, category count) and decline / accept / save actions.
All category labels, descriptions, buttons, statuses, and toasts are i18n keys in the cookies
namespace; the per-cookie name / provider / duration values are literal demo data (proper nouns).
Architecture & files
| File | Responsibility |
|---|---|
src/pages/cookies/CookieBannerDemoPage.tsx | The v1/v2 demo page (variant: 'bar' | 'box'): PageHeader, a status Panel (consent status line + "Show banner again" / "Manage cookies" buttons), the CookieBanner, and the preferences modal. |
src/pages/cookies/CookiesV3Page.tsx | The v3 demo page: intro card, AnimatedNumber stat tiles (TOTAL_COOKIES, category count), the inline CookieCategories list, and decline / accept-all / save actions. |
src/components/cookies/useConsent.ts | The view-model: working per-category prefs, the derived ConsentStatus, banner visibility, Manage-modal state, and the acceptAll / declineAll / savePrefs / reset actions (persist + toast). |
src/components/cookies/CookieBanner.tsx | The fixed consent banner. bar = full-width strip pinned to the bottom; box = compact card in the bottom-right corner. Motion fade + rise entrance (reduced-motion gated); hidden once decided. |
src/components/cookies/CookieCategories.tsx | The per-category rows: icon + label + description + cookie count + Switch, each expandable (via Collapse) to a cookie table. Locked categories show an "Always on" badge instead of a toggle. |
src/components/cookies/CookiePreferences.tsx | The "Cookie preferences" Modal (size lg): intro copy + CookieCategories + decline / save / accept-all footer. |
src/components/cookies/index.ts | Barrel: useConsent, CookieBanner, CookieCategories, CookiePreferences + types. |
src/data/cookies.ts | Types (CookieCategory, CookieDetail, ConsentState), the 4-category demo data, TOTAL_COOKIES, baseConsent, and persistence (STORAGE_KEY, loadConsent / saveConsent / clearConsent). |
Data model
export interface CookieDetail {
name: string
provider: string
duration: string
}
export interface CookieCategory {
id: string
labelKey: string // i18n key, e.g. 'cookies:catAnalytics'
descKey: string
icon: LucideIcon
cookies: CookieDetail[] // drives the detail table + counts
locked?: boolean // always-on (can't be toggled off)
}
/** Per-category allow/deny. Locked categories are always `true`. */
export type ConsentState = Record<string, boolean>baseConsent(on) builds a ConsentState with every non-locked category set to on (locked ones
stay true) — it backs "Accept all", "Decline all", and the pristine default.
Persistence
export const STORAGE_KEY = 'cookie-consent-v1'saveConsent writes the choice; loadConsent() returns the saved state or null when the visitor
hasn't decided yet (that null is what should gate a real banner — see Usage). On load
the saved value is merged over baseConsent(false) with necessary forced true, so a category
added after a visitor consented safely defaults to off and Necessary can never be persisted off.
Tip
STORAGE_KEY is registered in src/lib/appStorage.ts, so the shell's "Reset to defaults" wipes
it along with every other app store.
Usage
The three demo pages are already wired — Pages → Cookies in the sidebar, registered in
src/routes.tsx:
{path: '/cookies/v1', element: <CookieBannerDemoPage variant="bar" />},
{path: '/cookies/v2', element: <CookieBannerDemoPage variant="box" />},
{path: '/cookies/v3', element: <CookiesV3Page />},Demo pages vs. a real site banner
The demo pages always show the banner on load — a deliberate showcase behavior (each style stays
viewable, and accepting on v1 doesn't hide v2/v3 via the shared consent key). useConsent seeds
bannerVisible with useState(true) and documents the difference in a comment. Accept / decline /
save still persist and toast for real.
To wire the real banner app-wide:
Gate the initial visibility on the saved choice
In src/components/cookies/useConsent.ts:
const [bannerVisible, setBannerVisible] = useState(() => loadConsent() === null)Mount the banner + preferences modal once in the shell
For example in AppLayout:
import {CookieBanner, CookiePreferences, useConsent} from '@/components/cookies'
function ConsentGate() {
const vm = useConsent()
return (
<>
<CookieBanner vm={vm} variant="bar" />
<CookiePreferences vm={vm} />
</>
)
}Gate your actual scripts on the saved state
import {loadConsent} from '@/data/cookies'
if (loadConsent()?.analytics) {
// load your analytics snippet
}The banner then shows only until a choice is made, and stays hidden on every later visit.
API / Props
useConsent()
Returns the consent view-model (UseConsent):
| Key | Type | Description |
|---|---|---|
prefs | ConsentState | The working per-category preferences (seeded from loadConsent(), else all-off). |
setPref | (id, value) => void | Toggle one category in the working prefs (not persisted until savePrefs). |
status | ConsentStatus | 'pending' | 'accepted' | 'declined' | 'custom' — derived from the last saved state. |
bannerVisible | boolean | Whether the banner shows (demo: true on load — see Usage). |
manageOpen | boolean | Whether the preferences modal is open. |
openManage / closeManage | () => void | Open / close the preferences modal. |
acceptAll | () => void | Persist baseConsent(true), hide banner + modal, success toast. |
declineAll | () => void | Persist baseConsent(false) (Necessary stays on), hide banner + modal, success toast. |
savePrefs | () => void | Persist the current working prefs (with necessary forced on), hide banner + modal, success toast. |
reset | () => void | clearConsent(), reset prefs to all-off, show the banner again, info toast. |
Every commit path forces necessary: true before saving.
CookieBanner
| Prop | Type | Description |
|---|---|---|
vm | UseConsent | The consent view-model. |
variant | 'bar' | 'box' | bar = full-width strip pinned to the bottom (Decline / Manage / Accept all); box = corner card (Accept cookies / Manage; its close button counts as decline). |
Both variants render null when vm.bannerVisible is false, carry a primary accent (top border /
strip), and sit at z-[46] — above the chat rail, below the Modal's z-index so "Manage" opens
on top. The bar stops at lg:right-16 and the box shifts to lg:right-20 so neither hides behind
the 4rem chat rail.
CookieCategories
| Prop | Type | Description |
|---|---|---|
prefs | ConsentState | Current per-category values. |
setPref | (id, value) => void | Called when a category Switch toggles. |
Each row expands (first category open by default) to a cookie table (colCookie / colProvider /
colDuration). The expand button and the Switch are siblings, never nested buttons. Locked
categories render an "Always on" success badge instead of a toggle.
CookiePreferences
| Prop | Type | Description |
|---|---|---|
vm | UseConsent | Drives open/onClose, the category list, and the decline / save / accept-all footer. |
CookieBannerDemoPage
| Prop | Type | Description |
|---|---|---|
variant | 'bar' | 'box' | Which banner style the page demos (v1 = bar, v2 = box). |
Configuration & customization
Add / edit a category
Categories live in COOKIE_CATEGORIES (src/data/cookies.ts). Add an entry with a unique id, a
labelKey + descKey in the cookies namespace, a lucide icon, and its sample cookies list —
then add the labels to the locale files:
// src/data/cookies.ts
{
id: 'functional',
labelKey: 'cookies:catFunctional',
descKey: 'cookies:catFunctionalDesc',
icon: Puzzle,
cookies: [{name: 'ab_variant', provider: 'Luminaux', duration: '30 days'}],
},// src/locales/en/cookies.json
{"catFunctional": "Functional", "catFunctionalDesc": "Enable enhanced functionality."}Counts (TOTAL_COOKIES, the per-row {{count}} cookies label, the v3 stat tiles) and the toggle
list all derive from the array automatically. Returning visitors who consented before the addition
get the new category as off (the loadConsent merge).
Lock a category
Set locked: true — the toggle becomes an "Always on" badge, baseConsent always keeps it true,
and loadConsent can't restore it as off. (Only necessary is additionally hard-forced on every
save.)
Banner copy & links
The title, body, and the "What is a cookie?" / "Cookie Policy" links come from cookies:bannerTitle,
cookies:bannerText, cookies:whatIsCookie, cookies:cookiePolicy. The link hrefs are #
placeholders in CookieBanner.tsx — point them at your real policy pages.
Styling
Everything is semantic-token based (bg-surface, border-border, bg-primary/10, text-success,
…) so the banner, rows, and tables re-skin and dark-mode automatically — see
Design Tokens & Dark Mode. Never hardcode hex.
Examples
Read the saved consent anywhere
import {loadConsent} from '@/data/cookies'
const consent = loadConsent() // null until the visitor decides
if (consent?.marketing) {
// consent given for marketing cookies
}Reuse the category list in your own settings page
import {CookieCategories, useConsent} from '@/components/cookies'
function PrivacyTab() {
const vm = useConsent()
return (
<>
<CookieCategories prefs={vm.prefs} setPref={vm.setPref} />
<Button onClick={vm.savePrefs}>Save preferences</Button>
</>
)
}This is exactly what CookiesV3Page does (plus stat tiles).
Best practices
- Keep logic in
useConsent. The banner, modal, and category list are presentational — pass the view-model down rather than duplicating persistence. - One consent source. All three surfaces (banner, modal, inline panel) read/write the same
cookie-consent-v1key; don't fork per-page state. - Gate scripts on
loadConsent(). The kit records consent — actually loading/blocking trackers based on it is your integration point. - Store keys, not copy. Category labels, buttons, statuses, and toasts are i18n keys in
en/cookies.json(ja is seeded at parity); only cookie names / providers / durations stay literal. - Register new persisted keys. If you add another
localStoragekey, add it toAPP_LOCAL_KEYSinsrc/lib/appStorage.tsso Reset clears it.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| The banner shows again after I accepted and reloaded | Demo behavior by design — the showcase pages always show the banner on load. For a real site, seed bannerVisible from loadConsent() === null (see Usage). |
| The corner box hides behind the chat rail | It shifts with lg:right-20 (the bar uses lg:right-16) to clear the 4rem rail. Keep those offsets if you restyle the banner. |
| The Manage modal opens under the banner | It shouldn't — the banner is z-[46], below the Modal layer. If you raise the banner's z-index past the modal's, "Manage" will be covered. |
| The Necessary toggle can't be turned off | Intentional: locked: true renders an "Always on" badge, and every save path forces necessary: true. |
| Closing the box banner shows "Non-essential declined" | The close button deliberately maps to declineAll — dismissing without a choice is treated as declining optional cookies. |
| A newly added category is off for existing visitors | Expected: loadConsent merges the saved state over baseConsent(false), so unknown categories default to off until re-consented. |
FAQ
Does this actually block tracking scripts? No — it records and persists the visitor's choice.
Wire your analytics/marketing snippets to loadConsent() yourself (see Examples).
What's the difference between v1, v2, and v3? Same state, three presentations: v1 is the classic
full-width bottom bar, v2 is a compact corner card, v3 skips the banner and shows the granular
controls inline (the pattern for a privacy-settings page). v1 and v2 are one component
(CookieBannerDemoPage) with a variant prop.
Where does the "N cookies" count come from? The length of each category's cookies array in
src/data/cookies.ts; TOTAL_COOKIES sums them for the v3 stat tile.
How do I clear a saved choice? clearConsent() (the demo pages' "Show banner again" button calls
it via vm.reset), or "Reset to defaults", which wipes cookie-consent-v1 with every other app key.
Is the consent state versioned? The key is suffixed -v1; if you change the shape, bump the key
so stale states are ignored (and register the new key in appStorage.ts).
Notes for designers & content editors
- All copy — banner text, category labels/descriptions, buttons, statuses, toasts, table column
headers — lives in
src/locales/<lng>/cookies.json. Edit there, never in the components. - Colors are semantic tokens throughout (primary accents,
successbadge, muted tables), so the kit re-skins and dark-modes automatically. - Cookie tables (name · provider · duration) are literal demo values in
src/data/cookies.ts— replace them with your real cookie inventory; names render in thefont-dataface. - Category icons are lucide icons set per category in
COOKIE_CATEGORIES.
Related
Overlays & Disclosure
The Modal behind the preferences dialog.
Panel
The portlet the demo pages present their content in.
Core & Feedback
Button, Badge, Switch, and Toast.
Invoice Builder
A sibling Pages-section feature with the same data-module + hook idiom.
Internationalization
The cookies namespace and how languages load.
Was this page helpful?
