Consent Manager
A client-only GDPR cookie-consent banner with Accept all / Reject all / Manage preferences. Per-category switches (necessary always on, analytics, marketing) persist to localStorage and dispatch a consent-change event — no network, no CSRF.
Preview
We value your privacy
We use cookies to enhance your browsing experience, serve personalised content, and analyse our traffic. You can choose which categories to allow, and change your choice at any time. Cookie policy
-
Required for the site to function. Always on.
-
Helps us understand how visitors interact with the site.
-
Used to deliver personalised advertising relevant to you.
{{-- `persist="false"` keeps the demo choice in memory; a footer "Cookie settings"
link dispatches `consent:open` to bring the banner back with preferences open. --}}
<div x-data class="flex w-full flex-col gap-4">
<div class="w-full rounded-lg border border-border bg-card p-4">
<x-ui.consent-manager :inline="true" :persist="false" policy-url="#" />
</div>
<p class="text-center text-sm text-muted-foreground">
<button type="button" @click="$dispatch('consent:open')" class="underline underline-offset-4 hover:text-foreground">{{ __('Cookie settings') }}</button>
</p>
</div>
Installation
php artisan ui:add consent-manager
Note
This component ships an Alpine behavior module at
resources/js/ui/consent-manager.js. Import it once from your bundle so it registers on alpine:init:
import './consent-manager.js';
Registry contract
php artisan ui:add consent-manager
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/consent-manager.blade.php -
resources/js/ui/consent-manager.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Consent Manager (`consent-manager`)
A client-only GDPR cookie-consent banner with Accept all / Reject all / Manage preferences. Per-category switches (necessary always on, analytics, marketing) persist to localStorage and dispatch a consent-change event — no network, no CSRF.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add consent-manager
```
## Usage
```blade
{{-- `persist="false"` keeps the demo choice in memory; a footer "Cookie settings"
link dispatches `consent:open` to bring the banner back with preferences open. --}}
<div x-data class="flex w-full flex-col gap-4">
<div class="w-full rounded-lg border border-border bg-card p-4">
<x-ui.consent-manager :inline="true" :persist="false" policy-url="#" />
</div>
<p class="text-center text-sm text-muted-foreground">
<button type="button" @click="$dispatch('consent:open')" class="underline underline-offset-4 hover:text-foreground">{{ __('Cookie settings') }}</button>
</p>
</div>
```
## Props
- `storageKey` (string, default `cookie-consent`) — localStorage key the choice persists under.
- `inline` (bool, default `false`) — Renders in normal document flow (for docs previews) instead of a fixed bottom banner.
- `persist` (bool, default `true`) — When false, keeps the decision in memory only, for demos; when true, stores it in localStorage.
- `policyUrl` (mixed|null, default `null`) — Link to the cookie or privacy policy, shown as a link in the banner text.
- `policyVersion` (string, default `1`) — Bump when the policy changes so an existing stored decision is asked again.
- `maxAgeDays` (int, default `180`) — Lifetime of a stored decision in days before the banner reappears.
- `honorGpc` (bool, default `true`) — When true, treats the browser's Global Privacy Control signal as an automatic refusal.
## Use when
- Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
- Asking visitors for cookie consent under GDPR-style regulation, with granular category control.
- Needing a client-only banner that persists a decision in localStorage without any server round-trip.
## Avoid when
- Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
- Consent must be recorded server-side for audit purposes; this component keeps state in the browser only.
- Presenting a simple, non-legal dismissible announcement; use system-banner instead.
## Anti-patterns
- Long forms in dialogs
- Multi-step or comparison-heavy modal workflows
## Rules
- Use the `<brok:consent-manager>` tag (or `<x-ui.consent-manager>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/consent-manager
- Registry JSON (files, props, contract): https://brokui.dev/r/open/consent-manager.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| storageKey | string | cookie-consent | localStorage key the choice persists under. |
| inline | bool | false | Renders in normal document flow (for docs previews) instead of a fixed bottom banner. |
| persist | bool | true | When false, keeps the decision in memory only, for demos; when true, stores it in localStorage. |
| policyUrl | mixed | null | null | Link to the cookie or privacy policy, shown as a link in the banner text. |
| policyVersion | string | 1 | Bump when the policy changes so an existing stored decision is asked again. |
| maxAgeDays | int | 180 | Lifetime of a stored decision in days before the banner reappears. |
| honorGpc | bool | true | When true, treats the browser's Global Privacy Control signal as an automatic refusal. |
Slots
default— Extra copy shown under the banner description, for example links to a privacy policy and website terms.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Accept all, Reject all, and Manage preferences drive per-category switches (necessary is always on; analytics and marketing are optional).
- The decision persists to localStorage (when persist is true) and dispatches a consent-change event so the host page can act on it.
- Dispatching a window consent:open event reopens the banner, for example from a footer "cookie settings" link.
- No network request or CSRF token is involved; everything runs client-side.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Handle a short focused interruption or blocking decision.
Use when
- Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
- Asking visitors for cookie consent under GDPR-style regulation, with granular category control.
- Needing a client-only banner that persists a decision in localStorage without any server round-trip.
Avoid when
- Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
- Consent must be recorded server-side for audit purposes; this component keeps state in the browser only.
- Presenting a simple, non-legal dismissible announcement; use system-banner instead.
Use instead
- Inline content
- Drawer or dedicated page for longer tasks
Anti-patterns
- Long forms in dialogs
- Multi-step or comparison-heavy modal workflows
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- The banner renders as a labelled region (role="region" with an aria-label) so assistive tech can locate it.
- Category switches are real switch controls, so their state is announced and toggled with the keyboard like any other switch.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="consent-manager-{{ $record->id }}">
{{-- `persist="false"` keeps the demo choice in memory; a footer "Cookie settings"
link dispatches `consent:open` to bring the banner back with preferences open. --}}
<div x-data class="flex w-full flex-col gap-4">
<div class="w-full rounded-lg border border-border bg-card p-4">
<x-ui.consent-manager :inline="true" :persist="false" policy-url="#" />
</div>
<p class="text-center text-sm text-muted-foreground">
<button type="button" @click="$dispatch('consent:open')" class="underline underline-offset-4 hover:text-foreground">{{ __('Cookie settings') }}</button>
</p>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:consent-manager
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// localStorage key the choice persists under.
'storageKey' => 'cookie-consent',
// When true, render in normal document flow (for docs preview) instead of a
// fixed bottom banner.
'inline' => false,
// false keeps the decision in memory only (a demo); true stores it.
'persist' => true,
// Link to the cookie / privacy policy, shown in the banner text.
'policyUrl' => null,
// Bump when the policy changes: an older decision is asked again.
'policyVersion' => '1',
// Lifetime of a decision in days before it is asked again.
'maxAgeDays' => 180,
// Honour the browser's Global Privacy Control signal as a refusal.
'honorGpc' => true,
])
@php
// Toggle between two fixed full class strings so Tailwind's JIT sees both —
// never splice the prop into a utility.
$position = $inline
? 'relative w-full'
: 'fixed inset-x-0 bottom-0 z-popover w-full px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-4';
@endphp
{{-- Consent Manager: a client-only GDPR cookie banner. The decision is stored in
localStorage (no network, no CSRF); "Manage preferences" reveals per-category
switches. State + persistence live in consent-manager.js (uiConsentManager). --}}
<section
data-slot="consent-manager"
role="region"
aria-label="{{ __('Cookie consent') }}"
x-data="uiConsentManager({ storageKey: @js($storageKey), persist: @js((bool) $persist), policyVersion: @js((string) $policyVersion), maxAgeDays: @js((int) $maxAgeDays), honorGpc: @js((bool) $honorGpc) })"
@consent:open.window="reopen()"
x-show="visible"
x-cloak
x-transition:enter="transition ease-out duration-200 motion-reduce:transition-none"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150 motion-reduce:transition-none"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2"
{{ $attributes->merge(['class' => $position]) }}
>
<x-ui.card class="mx-auto max-w-2xl shadow-lg">
<div class="flex flex-col gap-4 p-5">
<div class="flex flex-col gap-2">
<h2 class="text-base font-semibold text-foreground">{{ __('We value your privacy') }}</h2>
<p class="text-sm text-muted-foreground">
{{ __('We use cookies to enhance your browsing experience, serve personalised content, and analyse our traffic. You can choose which categories to allow, and change your choice at any time.') }}
@if ($policyUrl)
<a href="{{ $policyUrl }}" class="underline underline-offset-4 hover:text-foreground">{{ __('Cookie policy') }}</a>
@endif
</p>
{{-- Default slot: extra copy under the description, for example links
to a privacy policy and website terms. --}}
@if (! $slot->isEmpty())
<div data-slot="consent-manager-extra" class="text-sm text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a:hover]:text-foreground">{{ $slot }}</div>
@endif
</div>
{{-- Preferences panel: revealed by "Manage preferences". --}}
<div
x-show="panelOpen"
x-cloak
x-transition:enter="transition ease-out duration-200 motion-reduce:transition-none"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
>
<x-ui.separator class="mb-4" />
<ul role="list" class="flex flex-col gap-4">
<li class="flex flex-col gap-1">
<div class="flex items-center justify-between gap-3">
<x-ui.label for="consent-necessary">{{ __('Strictly necessary') }}</x-ui.label>
<x-ui.switch id="consent-necessary" checked disabled aria-describedby="consent-necessary-desc" />
</div>
<p id="consent-necessary-desc" class="text-xs text-muted-foreground">
{{ __('Required for the site to function. Always on.') }}
</p>
</li>
<li class="flex flex-col gap-1">
<div class="flex items-center justify-between gap-3">
<x-ui.label for="consent-analytics">{{ __('Analytics') }}</x-ui.label>
<x-ui.switch id="consent-analytics" x-model="prefs.analytics" aria-describedby="consent-analytics-desc" />
</div>
<p id="consent-analytics-desc" class="text-xs text-muted-foreground">
{{ __('Helps us understand how visitors interact with the site.') }}
</p>
</li>
<li class="flex flex-col gap-1">
<div class="flex items-center justify-between gap-3">
<x-ui.label for="consent-marketing">{{ __('Marketing') }}</x-ui.label>
<x-ui.switch id="consent-marketing" x-model="prefs.marketing" aria-describedby="consent-marketing-desc" />
</div>
<p id="consent-marketing-desc" class="text-xs text-muted-foreground">
{{ __('Used to deliver personalised advertising relevant to you.') }}
</p>
</li>
</ul>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<x-ui.button
variant="link"
size="sm"
@click="panelOpen = !panelOpen"
class="sm:me-auto"
>
<span x-show="!panelOpen">{{ __('Manage preferences') }}</span>
<span x-show="panelOpen" x-cloak>{{ __('Hide preferences') }}</span>
</x-ui.button>
{{-- Reject and Accept share one recipe: refusing must be as easy and
as visible as consenting (EDPB guidelines, CNIL). --}}
<x-ui.button variant="outline" size="sm" @click="rejectAll()">{{ __('Reject all') }}</x-ui.button>
<x-ui.button
x-show="panelOpen"
x-cloak
variant="default"
size="sm"
@click="savePreferences()"
>{{ __('Save preferences') }}</x-ui.button>
<x-ui.button variant="outline" size="sm" @click="acceptAll()">{{ __('Accept all') }}</x-ui.button>
</div>
</div>
</x-ui.card>
</section>
/**
* Consent Manager behaviour (client-only GDPR cookie consent).
*
* Renders a bottom banner with Accept all / Reject all / Manage preferences. The
* preferences panel lists per-category switches (necessary is always on). The
* choice is persisted to localStorage under `storageKey` — there is NO network
* call and NO CSRF; everything is local. On `init()` we read any stored decision
* and start hidden if one exists, so returning visitors aren't re-prompted.
*
* Each persist writes `{ necessary, analytics, marketing, ts }` and dispatches a
* `consent-change` CustomEvent so the host page can react (load analytics, etc).
*
* A stored decision is not final: `reopen()` — or a `consent:open` event on the
* window (the "Cookie settings" link every footer needs) — shows the banner
* again with the preferences panel open and the saved choices filled in.
* `persist: false` keeps everything in memory (a demo, a preview).
*
* Consent hygiene (GDPR / ePrivacy practice, not legal advice):
* - a decision expires after `maxAgeDays` (default 180) and is asked again;
* - a decision made under another `policyVersion` is asked again;
* - the record carries `ts`, `version` and `source` (banner | gpc), the proof
* of consent a controller must be able to show;
* - a Global Privacy Control signal (`navigator.globalPrivacyControl`) is
* honoured as an objection: non-essential categories are recorded as
* refused without prompting (`honorGpc`, default true).
*
* No global listeners/timers/observers are added, but a `destroy()` is defined to
* clear local state on teardown for hygiene.
*
* Self-registers on `alpine:init` so import order does not matter.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiConsentManager', (config = {}) => ({
storageKey: typeof config.storageKey === 'string' && config.storageKey ? config.storageKey : 'cookie-consent',
persistChoice: config.persist ?? true,
policyVersion: String(config.policyVersion ?? '1'),
maxAgeDays: Number(config.maxAgeDays ?? 180),
honorGpc: config.honorGpc ?? true,
visible: true,
panelOpen: false,
prefs: { necessary: true, analytics: false, marketing: false },
init() {
const stored = this.persistChoice ? this.read() : null;
if (stored && this.isCurrent(stored)) {
// A valid decision already exists — honour it and stay hidden.
this.prefs = {
necessary: true,
analytics: Boolean(stored.analytics),
marketing: Boolean(stored.marketing),
};
this.visible = false;
return;
}
if (this.honorGpc && navigator.globalPrivacyControl === true) {
// The browser already says no to non-essential processing.
this.prefs.analytics = false;
this.prefs.marketing = false;
this.persist('gpc');
this.visible = false;
}
},
/** A stored decision counts only under the current policy and within its lifetime. */
isCurrent(stored) {
if (String(stored.version ?? '1') !== this.policyVersion) return false;
const age = Date.now() - Number(stored.ts ?? 0);
return age >= 0 && age <= this.maxAgeDays * 86400000;
},
read() {
try {
const raw = window.localStorage.getItem(this.storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (e) {
return null;
}
},
acceptAll() {
this.prefs.analytics = true;
this.prefs.marketing = true;
this.persist();
this.visible = false;
},
rejectAll() {
this.prefs.analytics = false;
this.prefs.marketing = false;
this.persist();
this.visible = false;
},
savePreferences() {
this.persist();
this.visible = false;
},
/** Show the banner again (preferences open) so a saved choice can be changed. */
reopen() {
this.panelOpen = true;
this.visible = true;
},
persist(source = 'banner') {
const detail = {
necessary: true,
analytics: Boolean(this.prefs.analytics),
marketing: Boolean(this.prefs.marketing),
ts: Date.now(),
version: this.policyVersion,
source,
};
try {
if (this.persistChoice) window.localStorage.setItem(this.storageKey, JSON.stringify(detail));
} catch (e) {
// Storage unavailable (private mode / quota) — still emit so the
// host can react this session.
}
this.emit(detail);
},
emit(detail) {
window.dispatchEvent(new CustomEvent('consent-change', { detail }));
},
destroy() {
this.prefs = { necessary: true, analytics: false, marketing: false };
this.panelOpen = false;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0