---
title: Headless
description: Render your own consent banner and preferences in JavaScript, or
  connect a framework without a c15t adapter such as Solid, with
  createConsentRuntime from c15t/runtime.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Choose a headless API

All three run the same consent engine. They differ in how much of the page
lifecycle they handle for you.

|API|Use it when|
|--|--|
|`createConsentRuntime` from `c15t/runtime`|You render the UI yourself, connect a UI framework, or share one consent state between several parts of the page. This page covers it.|
|`init` from `@c15t/browser/headless`|You want the `@c15t/browser` client without its UI: `data-c15t-action` buttons, `on('ui', ...)` and `acceptAll()` work as with the stock UI.|
|`createConsentKernel` from `c15t`|You assemble every module yourself. See the [consent kernel API](/docs/frameworks/javascript/api/kernel#create-a-kernel-yourself).|

The [createConsentRuntime reference](/docs/frameworks/javascript/api/runtime)
lists every option and method of the runtime.

`createConsentRuntime` builds the kernel and connects stored choices, script
loading, iframe gating and the reload after a visitor withdraws permission. It
also adds the network blocker, data clearing and IAB when you configure them.
A kernel you create yourself has none of these until you add them.

## Install

|Package manager|Command|
|:--|:--|
|npm|`npm install c15t@alpha @c15t/integrations@alpha`|
|pnpm|`pnpm add c15t@alpha @c15t/integrations@alpha`|
|yarn|`yarn add c15t@alpha @c15t/integrations@alpha`|
|bun|`bun add c15t@alpha @c15t/integrations@alpha`|

`@c15t/integrations` supplies vendor helpers. Leave it out if you gate no vendor
scripts.

## Create the runtime

Create one runtime for the page, in its own module, so every part of your UI
imports the same instance:

```ts title="src/consent-runtime.ts"
import { hosted } from 'c15t';
import { createConsentRuntime } from 'c15t/runtime';

import { scripts } from './scripts';

// One runtime for the page: policy, stored choices, script loading,
// iframe blocking and the reload after a revocation.
export const runtime = createConsentRuntime({
	mode: hosted({ backendURL: 'https://your-project.inth.app' }),
	scripts,
});
```

Replace `https://your-project.inth.app` with your project's backend URL,
including any path prefix. `scripts` is the list from `src/scripts.ts` in the
[quickstart](/docs/frameworks/javascript/quickstart#register-consent-gated-scripts).
Creating the runtime has no side effects. It reads no storage and sends no
request until you call `start()`, so the module can also load on a server.

## Render the banner from the snapshot

`runtime.kernel.getSnapshot()` returns the current consent state, and
`runtime.kernel.subscribe(listener)` calls the listener with each new one.
Your UI renders from these fields; the [snapshot reference](/docs/frameworks/javascript/api/snapshot)
lists the rest:

|Field|What it tells your UI|
|--|--|
|`policyPending`, `resolution.status`|Whether the policy has resolved. Render nothing optional until `resolution.status` is `'matched'`.|
|`activeUI`|Which surface to show: `'banner'`, `'dialog'` or `'none'`.|
|`promptRequirement.kind`|`'choice'` needs a decision, `'notice'` needs only a dismissal, `'none'` needs no prompt.|
|`policyRule.scope`|The categories to offer in preferences.|
|`effectivePermissions`|Whether each category is allowed right now. Use it to gate features.|
|`explicitChoice`|What the visitor recorded, if anything.|

`resolveConsentPresentation` from `c15t` turns the policy into the buttons each
surface must show, in order. This module renders them and turns each click into
a kernel command:

```ts title="src/consent-ui.ts"
const label: Record<PresentationAction, string> = {
	accept: 'Accept all',
	customize: 'Choose cookies',
	dismiss: 'OK',
	reject: 'Reject optional',
	save: 'Save preferences',
};

// Each button is a visitor action. Only these calls record a choice.
const perform = async function perform(action: PresentationAction) {
	if (action === 'customize') {
		kernel.set.activeUI('dialog');
		return;
	}
	if (action === 'dismiss') {
		await kernel.commands.dismissNotice();
		return;
	}
	let choice: Partial<ConsentState> | 'all' | 'none' =
		action === 'accept' ? 'all' : 'none';
	if (action === 'save') {
		choice = {};
		for (const input of fields.querySelectorAll<HTMLInputElement>('input')) {
			const category = latest.policyRule.scope.find(
				(name) => name === input.name
			);
			if (category) {
				choice[category] = input.checked;
			}
		}
	}
	kernel.set.activeUI('none');
	const result = await kernel.commands.save(choice);
	status.textContent = result.ok
		? 'Your preferences are saved.'
		: 'Saved in this browser. Backend delivery will retry.';
};

// The policy decides which actions each surface offers, and in what order.
const renderActions = function renderActions(
	container: HTMLElement,
	surface: 'prompt' | 'preferences',
	snapshot: ConsentSnapshot
) {
	const presentation = resolveConsentPresentation({
		policy: snapshot.policyRule,
		surface,
	});
	const buttons = presentation.orderedActions.map((action) => {
		const button = document.createElement('button');
		button.type = 'button';
		button.textContent = label[action];
		button.addEventListener('click', () => {
			void perform(action);
		});
		return button;
	});
	if (surface === 'prompt') {
		for (const right of presentation.rights) {
			const button = document.createElement('button');
			button.type = 'button';
			button.textContent =
				right === 'opt-out'
					? 'Do not sell or share my data'
					: 'Privacy settings';
			button.addEventListener('click', () => kernel.set.activeUI('dialog'));
			buttons.push(button);
		}
	}
	container.replaceChildren(...buttons);
};
```

In this file, `kernel` is `runtime.kernel`, `fields` is the element holding the
preference checkboxes, `latest` is the last snapshot the UI rendered, and
`status` is a live region for messages. `kernel.set.activeUI()` opens and closes
surfaces without recording anything. `kernel.commands.save()` records a choice:
`'all'` accepts, `'none'` rejects, and an object such as
`{ measurement: true }` saves those categories. `dismissNotice()` records that
the visitor saw a notice, not that they consented.

## Build a preference form with a draft

A preference form shows switches the visitor can move and keep moving before
anything is recorded. `createPreferenceDraft(kernel)` from
`c15t/preference-draft` holds those unsaved choices. It is the same draft the
React, Vue, Svelte and `@c15t/browser` preference dialogs use:

```ts title="src/preference-form.ts"
import type { AllConsentNames } from 'c15t';
import { createPreferenceDraft } from 'c15t/preference-draft';
import type { ConsentRuntime } from 'c15t/runtime';

// Render a form of category switches that records nothing until Save.
export const mountPreferenceForm = function mountPreferenceForm(
	runtime: ConsentRuntime,
	form: HTMLFormElement
): () => void {
	const draft = createPreferenceDraft(runtime.kernel);

	const render = () => {
		const { displayedCategories, isStale, values } = draft.getState();
		form.replaceChildren(
			...displayedCategories.map((category: AllConsentNames) => {
				const label = document.createElement('label');
				const input = document.createElement('input');
				input.type = 'checkbox';
				input.checked = values[category];
				input.disabled = category === 'necessary';
				input.addEventListener('change', () => {
					draft.set(category, input.checked);
				});
				label.append(input, ` ${category}`);
				return label;
			})
		);
		if (isStale) {
			// The policy changed under an unsaved edit: Save records nothing
			// until the visitor reviews the current choices.
			const review = document.createElement('button');
			review.type = 'button';
			review.textContent = 'Review choices';
			review.addEventListener('click', draft.reset);
			form.append(review);
		}
		const save = document.createElement('button');
		save.textContent = 'Save';
		form.append(save);
	};

	form.addEventListener('submit', (event) => {
		event.preventDefault();
		void draft.save();
	});
	render();
	// The draft follows the record and the policy while subscribed.
	return draft.subscribe(render);
};
```

`draft.getState()` returns what the form renders:

|Field|What it holds|
|--|--|
|`displayedCategories`|`necessary` plus the categories the policy lets the visitor decide, always in the order necessary, functionality, measurement, experience, marketing.|
|`values`|Each category's value: a staged edit, else the recorded choice, else the policy default. Categories outside `displayedCategories` read `false`.|
|`vendors`|Each declared vendor's switch. Empty under an IAB policy.|
|`isDirty`|Whether any staged value differs from the record.|
|`isStale`|Whether the policy, the displayed categories or the vendor list changed under a staged edit.|

`set(category, value)` and `setVendor(id, granted)` stage a switch;
`draft.save()` records the displayed categories and only the vendors the
visitor moved. While subscribed, the draft follows the record: when another
surface or tab saves, switches the visitor left alone take the new value, and
staged ones keep theirs. A stale draft records nothing and `save()` resolves
`{ ok: false }` until `reset()`. `draft.save({ input: 'all' })` and
`{ input: 'none' }` record a bulk choice under the current policy and drop
every staged switch.

Load the draft only where a preference form renders. Pages that show only a
banner do not need it.

## Close surfaces the way the stock UI does

`kernel.commands.save()` records a choice. The kernel hides the banner once no
prompt is owed, but a dialog the visitor opened stays open. To close surfaces
the way every c15t adapter does, wrap your clicks in the functions from
`c15t/surface-actions`:

|Function|What it does|
|--|--|
|`saveConsentSurface(kernel, () => kernel.commands.save(input))`|Runs the save and closes the open banner or dialog once the choice is recorded, before the backend answers. A save that records nothing new closes when it resolves successfully. The banner stays only while the policy still owes a choice or a notice.|
|`saveConsentBlanket(kernel, 'all' \|'none', runtime.iab)`|Accept all or reject all. Under an IAB policy it goes through the CMP so the TC string records it.|
|`saveIABConsentSurface(kernel, () => runtime.iab.save())`|Closes an IAB surface in the click task and brings it back if the CMP recorded nothing, for example when the vendor list failed to load.|
|`showConsentSurface(kernel, 'banner' \|'dialog' \|'none')`|Opens or closes a surface. `'none'` with the dialog open brings the banner back while the policy still owes a choice or a notice. A pending save started before it can no longer close or reopen anything.|
|`hasConsentUI(snapshot)`|Whether the policy owes any c15t banner or dialog. `false` until a rule resolves, for a `none` rule without rights, and while an external CMP owns the decision.|
|`hasConsentPreferences(snapshot)`|Whether a privacy settings control has somewhere to go: `hasConsentUI`, or an external CMP.|

A draft's save records in the same call, so it closes the dialog the same way:
`saveConsentSurface(kernel, () => draft.save(), () => !draft.getState().isDirty)`.
A stale draft resolves `{ ok: false }`, so its dialog stays open. The last
argument stops a save that recorded nothing new from closing the dialog over
switches the visitor moved while it ran. The stock React and Vue dialogs do the
same.

[When a choice is saved](/docs/concepts/consent-state#when-a-choice-is-saved)
explains the order of the local record, storage and the backend request.

## Start and stop the runtime

Subscribe before you start, so the UI renders the resolved policy the moment
it arrives:

```ts title="src/consent-ui.ts"
const unsubscribe = kernel.subscribe(render);
const stopErrors = kernel.events.on('command:error', ({ command }) => {
	if (command === 'init') {
		status.textContent =
			'Consent could not load. Check the backend endpoint and allowed origin.';
	}
});
render(kernel.getSnapshot());
// Reads stored choices, resolves the policy and starts script loading.
runtime.start();

window.addEventListener('pagehide', (event) => {
	// Back and Forward restore a cached page with the same runtime.
	if (event.persisted) {
		return;
	}
	unsubscribe();
	stopErrors();
	runtime.dispose();
});
```

`runtime.start()` reads stored choices, requests the policy and starts script
loading. Call it once, in the browser. `runtime.dispose()` removes everything
`start()` set up. The `pagehide` check keeps the runtime alive when the
browser stores the page for Back and Forward navigation.

The complete script, with the preference dialog, is
`internals/doc-snippets/javascript/src/headless.ts` in the c15t repository.

## What your UI must handle

A custom UI takes on everything the stock banner does:

* **Every prompt kind.** Show a decision for `choice`, a dismissable notice for
  `notice`, and nothing for `none`. Do not assume every prompt is accept or
  reject, or that no prompt means consent.
* **The policy's required actions and rights.** Render every action
  `resolveConsentPresentation` returns, including rights such as an opt-out
  link.
* **A way back.** Keep a privacy settings control on every page after the
  banner closes.
* **Visitor actions only.** Call `save()` and `dismissNotice()` only from a
  click or key press, never on load or during rendering.
* **Accessibility.** Focus, keyboard use, labels, error states and narrow
  screens are yours. The example uses a native `<dialog>` for focus handling.
* **Copy.** `snapshot.translations` carries the resolved language's copy,
  if you want the same text as the stock UI. See
  [translations](/docs/frameworks/javascript/translations).

Read [how consent works](/docs/concepts/how-consent-works) for the difference
between a permission and a recorded choice, then run the
[verification checklist](/docs/guides/verify-consent).

## Use c15t with Solid

There is no published Solid adapter. Create the runtime as above and read it
through a signal:

```ts title="src/use-consent.ts"
import { createSignal, onCleanup } from 'solid-js';

import { runtime } from './consent-runtime';

export function useConsentSnapshot() {
	const [snapshot, setSnapshot] = createSignal(runtime.kernel.getSnapshot());
	onCleanup(runtime.kernel.subscribe((next) => setSnapshot(() => next)));
	return snapshot;
}
```

Call `runtime.start()` once in your entry file, after `render()`. Components
read `snapshot().effectivePermissions` and call `runtime.kernel.commands` from
event handlers, as in the example above. Other frameworks without an adapter
follow the same shape, with one runtime and one subscription per component
tree.

## Use the browser client without its UI

`@c15t/browser/headless` gives you the `@c15t/browser` client with no banner or
stylesheet. Page hooks such as `data-c15t-action="accept"` still work, and the
`ui` event says which surface to show:

```ts
import { init, manifest } from '@c15t/browser/headless';

const consent = init({ mode: manifest(), scripts });
consent.on('ui', (surface) => {
	banner.hidden = surface !== 'banner';
});
```

`scripts` is the list from the
[quickstart](/docs/frameworks/javascript/quickstart), and `banner` is your
banner element. Install `@c15t/browser@alpha` for this path.

## Check it works

Run the example or your app in a private window with the Network tab open.

1. Your banner appears once the policy resolves, with the actions
   `resolveConsentPresentation` returned. Vendor requests are absent.
2. Reject and reload. Your banner stays hidden and vendors stay blocked.
3. Open your preferences, allow Measurement only and save. Measurement
   vendors load and marketing vendors do not.
4. Tab through your banner and dialog. Every control is reachable, and
   Escape closes the dialog.
