---
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.
---
## 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.

| Package manager | Command                |
| :-------------- | :--------------------- |
| npm             | `npm install @c15t/ui` |
| pnpm            | `pnpm add @c15t/ui`    |
| yarn            | `yarn add @c15t/ui`    |
| bun             | `bun add @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),
  };
}
```
