PVR Tech Studio
Cookie consent

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.

9 min read
Updated July 15, 2026

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 with variant="bar" or variant="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 two AnimatedNumber stat 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

FileResponsibility
src/pages/cookies/CookieBannerDemoPage.tsxThe 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.tsxThe 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.tsThe 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.tsxThe 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.tsxThe 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.tsxThe "Cookie preferences" Modal (size lg): intro copy + CookieCategories + decline / save / accept-all footer.
src/components/cookies/index.tsBarrel: useConsent, CookieBanner, CookieCategories, CookiePreferences + types.
src/data/cookies.tsTypes (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.

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):

KeyTypeDescription
prefsConsentStateThe working per-category preferences (seeded from loadConsent(), else all-off).
setPref(id, value) => voidToggle one category in the working prefs (not persisted until savePrefs).
statusConsentStatus'pending' | 'accepted' | 'declined' | 'custom' — derived from the last saved state.
bannerVisiblebooleanWhether the banner shows (demo: true on load — see Usage).
manageOpenbooleanWhether the preferences modal is open.
openManage / closeManage() => voidOpen / close the preferences modal.
acceptAll() => voidPersist baseConsent(true), hide banner + modal, success toast.
declineAll() => voidPersist baseConsent(false) (Necessary stays on), hide banner + modal, success toast.
savePrefs() => voidPersist the current working prefs (with necessary forced on), hide banner + modal, success toast.
reset() => voidclearConsent(), reset prefs to all-off, show the banner again, info toast.

Every commit path forces necessary: true before saving.

CookieBanner

PropTypeDescription
vmUseConsentThe 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

PropTypeDescription
prefsConsentStateCurrent per-category values.
setPref(id, value) => voidCalled 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

PropTypeDescription
vmUseConsentDrives open/onClose, the category list, and the decline / save / accept-all footer.

CookieBannerDemoPage

PropTypeDescription
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.)

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

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-v1 key; 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 localStorage key, add it to APP_LOCAL_KEYS in src/lib/appStorage.ts so Reset clears it.

Troubleshooting

SymptomCause / fix
The banner shows again after I accepted and reloadedDemo 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 railIt 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 bannerIt 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 offIntentional: 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 visitorsExpected: 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, success badge, 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 the font-data face.
  • Category icons are lucide icons set per category in COOKIE_CATEGORIES.

Was this page helpful?