c15t
Version:
Headless JavaScript consent management platform for cookie banners, privacy preferences, consent storage, and script gating.
298 lines (228 loc) • 9.96 kB
Markdown
---
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),
};
}
```