Skip to content
Brok UI

Loading…

No results

Consent Manager

Open source

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.

Version
v1.3.2
Stability
stable
License
MIT
Related
Dialog
System Banner

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

previews.components.consent-manager.default.blade.php 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>

Installation

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

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/consent-manager.blade.php
  • js resources/js/ui/consent-manager.js
Registry dependencies
card button switch label separator
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

consent-manager.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

consent-manager consent-manager-extra

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

Focused overlay

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
root
Theming hooks
consent-manager consent-manager-extra

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/consent-manager.blade.php Blade
@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>
resources/js/ui/consent-manager.js JS
/**
 * 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