UNPKG

c15t

Version:

Headless JavaScript consent management platform for cookie banners, privacy preferences, consent storage, and script gating.

298 lines (228 loc) 9.96 kB
--- title: Building UI description: Build your own consent UI on top of the headless c15t store — with vanilla DOM, any framework, or the @c15t/ui theme system. group: frameworks --- ## Headless by Design The `c15t` core package provides no pre-built UI components. It gives you a Zustand vanilla store with all the consent state and actions you need — you bring the rendering layer. This makes it the foundation for building consent UI in any framework: vanilla JS, Vue, Svelte, Solid, or anything else. ## Vanilla DOM Example Here's a complete consent banner built with plain JavaScript and DOM APIs: ```ts import { getOrCreateConsentRuntime } from 'c15t'; const { consentStore } = getOrCreateConsentRuntime({ mode: 'hosted', backendURL: 'https://your-instance.c15t.dev', consentCategories: ['necessary', 'measurement', 'marketing'], }); // Create banner element function createBanner() { const banner = document.createElement('div'); banner.id = 'consent-banner'; banner.setAttribute('role', 'dialog'); banner.setAttribute('aria-label', 'Cookie consent'); banner.innerHTML = ` <p>We use cookies to improve your experience.</p> <div> <button id="consent-accept">Accept All</button> <button id="consent-reject">Reject</button> <button id="consent-customize">Customize</button> </div> `; document.body.appendChild(banner); banner.querySelector('#consent-accept')?.addEventListener('click', () => { consentStore.getState().saveConsents('all'); }); banner.querySelector('#consent-reject')?.addEventListener('click', () => { consentStore.getState().saveConsents('necessary'); }); banner.querySelector('#consent-customize')?.addEventListener('click', () => { consentStore.getState().setActiveUI('dialog'); }); return banner; } // Mount and subscribe const banner = createBanner(); consentStore.subscribe((state) => { banner.style.display = state.activeUI === 'banner' ? 'block' : 'none'; }); ``` ## Reactive State Subscriptions The store fires on every state change. Compare with the previous state to react only to specific UI changes: ```ts consentStore.subscribe((state, prevState) => { // React to activeUI changes (banner / dialog / none) if (state.activeUI !== prevState.activeUI) { document.getElementById('consent-banner')!.style.display = state.activeUI === 'banner' ? 'block' : 'none'; document.getElementById('consent-dialog')!.style.display = state.activeUI === 'dialog' ? 'block' : 'none'; } // React to other UI-level state changes here }); ``` For analytics SDKs or consent-mode integrations, use `subscribeToConsentChanges()` instead of diffing `consentStore.subscribe()` manually: ```ts consentStore .getState() .subscribeToConsentChanges(({ allowedCategories, deniedCategories }) => { analytics.syncConsent({ allowedCategories, deniedCategories }); }); ``` ## Building Policy-Aware UI If you use policy packs, your custom UI should render from the resolved runtime policy instead of hard-coding a fixed banner shape. That matters when different visitors may resolve to different experiences, for example: * a banner in one region and no banner in another * different action sets such as accept/customize without reject * different category scopes depending on the active policy You can preview that locally with `offlinePolicy.policyPacks`: ```ts import { getOrCreateConsentRuntime, policyPackPresets } from 'c15t'; const { consentStore } = getOrCreateConsentRuntime({ mode: 'offline', offlinePolicy: { policyPacks: [ policyPackPresets.californiaOptOut(), policyPackPresets.europeOptIn(), policyPackPresets.worldNoBanner(), ], }, overrides: { country: 'US', region: 'CA', }, }); const state = consentStore.getState(); console.log(state.policy?.id, state.policyDecision?.matchedBy); ``` When building custom UI, prefer these runtime values: * `activeUI` to decide whether to show banner, dialog, or nothing * `policy` to inspect the resolved mode and UI hints * `policyDecision` to debug why a policy was selected * `consentCategories` to decide which categories should render > ℹ️ **Info:** > See Policy Packs for offline preview setup and Policy Packs Concept for matcher precedence and fallback behavior. ## Key Store Properties for UI |Property|Type|Use| |--|--|--| |`activeUI`|`'none' \|'banner' \|'dialog'`|Which consent UI is currently visible| |`consents`|`Record<string, boolean>`|Current consent state per category| |`selectedConsents`|`Record<string, boolean>`|Staged toggle state (unsaved)| |`consentTypes`|`ConsentType[]`|Categories to display with title/description| |`model`|`string \|null`|Current consent model (opt-in, opt-out, iab)| |`locationInfo`|`LocationInfo \|null`|Detected location (country, region)| ## Key Store Actions for UI |Action|Signature|Use| |--|--|--| |`saveConsents`|`(type: 'all' \|'custom' \|'necessary') => Promise<void>`|Save consent choices| |`setSelectedConsent`|`(name, value) => void`|Toggle a category (staged, not saved)| |`setConsent`|`(name, value) => void`|Set + auto-save a single category| |`setActiveUI`|`(ui: 'none' \|'banner' \|'dialog', options?) => void`|Show/hide the banner or dialog| |`getDisplayedConsents`|`() => ConsentType[]`|Get visible consent categories| ## Validating Custom UI Against the Resolved Policy If you're building a reusable headless component library or framework wrapper, test your rendered UI against the resolved policy shape. ```ts import { getEffectivePolicy, type PolicyUIState, validateUIAgainstPolicy, } from 'c15t'; const policy = getEffectivePolicy(initData); const bannerState: PolicyUIState = { mode: 'banner', actions: ['accept', 'reject', 'customize'], layout: 'split', uiProfile: 'compact', scrollLock: false, }; const issues = validateUIAgainstPolicy({ policy, state: bannerState, }); if (issues.length > 0) { console.warn('UI does not match policy:', issues); } ``` This catches mismatches such as: * rendering actions the policy does not allow * rendering the wrong action order * using the wrong layout or UI profile * showing banner/dialog UI that does not match the resolved mode ## Using `@c15t/ui` for Theming The `@c15t/ui` package provides a framework-agnostic theme and style system. It defines design tokens, slot-based styling, and CSS primitives that you can use in any rendering layer. <PackageCommandTabs mode="install" command="@c15t/ui" /> ### Theme Tokens Use the token types to build a consistent theme system: ```ts import type { ColorTokens, TypographyTokens, SpacingTokens, BorderTokens, ShadowTokens, } from '@c15t/ui/theme'; // Define a theme matching the token structure const theme = { colors: { primary: '#2563eb', background: '#ffffff', text: '#1a1a1a', border: '#e5e7eb', } satisfies Partial<ColorTokens>, typography: { fontFamily: 'Inter, system-ui, sans-serif', fontSize: '14px', } satisfies Partial<TypographyTokens>, }; ``` ### Style Utilities `@c15t/ui` exports DOM utilities for working with class names and styles: ```ts import { cn, resolveStyles, sanitizeDOMStyleProps } from '@c15t/ui/utils'; // Merge class names conditionally const className = cn( 'consent-banner', isVisible && 'consent-banner--visible', isDark && 'consent-banner--dark' ); // Strip internal style metadata before binding to DOM const domProps = sanitizeDOMStyleProps(resolveStyles('consentBannerCard', theme)); ``` When building framework adapters, treat `noStyle` as internal control flow only. Always sanitize resolved slot styles before binding them to DOM elements, and prefer subtree-inherited CSS variables over hard-coded `:root` assumptions for token-driven primitives like switches. ### CSS Primitives Import style primitives for consent UI elements: ```ts import { buttonVariants, switchVariants, accordionVariants } from '@c15t/ui/styles/primitives'; // Generate class names for styled buttons const btn = buttonVariants({ variant: 'primary', mode: 'filled', size: 'medium' }); document.getElementById('accept-btn')!.className = btn.root(); // Generate class names for styled switches const sw = switchVariants({ size: 'medium' }); toggleEl.className = sw.root(); ``` These functions return CSS module class name generators. To apply the associated styles, import the stylesheet from your app-level CSS entrypoint: ```css title="src/index.css" @import "@c15t/ui/styles.css"; ``` ## Building a Framework Library > ℹ️ **Info:** > Building a Vue, Svelte, or other framework library? Use c15t for the consent logic and @c15t/ui for the theme/style system. See the @c15t/react source code for a reference implementation of how to wrap the core store in a framework-specific API. The pattern for building a framework wrapper: 1. **Create a provider** — Initialize `getOrCreateConsentRuntime()` and expose the store via your framework's context system (Vue `provide`/`inject`, Svelte context, etc.) 2. **Build reactive bindings** — Subscribe to store changes and bridge them to your framework's reactivity system 3. **Wrap UI components** — Use `@c15t/ui` tokens and slots for consistent theming, render with your framework's component model 4. **Export hooks/composables** — Expose store actions through framework-idiomatic APIs (Vue composables, Svelte stores, etc.) ```ts // Pseudocode for a framework wrapper import { getOrCreateConsentRuntime } from 'c15t'; import type { ConsentRuntimeOptions } from 'c15t'; export function createConsentPlugin(options: ConsentRuntimeOptions) { const { consentStore } = getOrCreateConsentRuntime(options); return { store: consentStore, has: (condition) => consentStore.getState().has(condition), saveConsents: (type) => consentStore.getState().saveConsents(type), subscribe: (selector, callback) => consentStore.subscribe(selector, callback), }; } ```