Skip to main content

JavaScript Customization

Headless

Choose a headless API

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

APIUse it when
createConsentRuntime from c15t/runtimeYou 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/headlessYou want the @c15t/browser client without its UI: data-c15t-action buttons, on('ui', ...) and acceptAll() work as with the stock UI.
createConsentKernel from c15tYou assemble every module yourself. See the consent kernel API.

The createConsentRuntime reference 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

npm install 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:

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. 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 lists the rest:

FieldWhat it tells your UI
policyPending, resolution.statusWhether the policy has resolved. Render nothing optional until resolution.status is 'matched'.
activeUIWhich surface to show: 'banner', 'dialog' or 'none'.
promptRequirement.kind'choice' needs a decision, 'notice' needs only a dismissal, 'none' needs no prompt.
policyRule.scopeThe categories to offer in preferences.
effectivePermissionsWhether each category is allowed right now. Use it to gate features.
explicitChoiceWhat 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:

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:

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:

FieldWhat it holds
displayedCategoriesnecessary plus the categories the policy lets the visitor decide, always in the order necessary, functionality, measurement, experience, marketing.
valuesEach category's value: a staged edit, else the recorded choice, else the policy default. Categories outside displayedCategories read false.
vendorsEach declared vendor's switch. Empty under an IAB policy.
isDirtyWhether any staged value differs from the record.
isStaleWhether 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:

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

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.

Read how consent works for the difference between a permission and a recorded choice, then run the verification checklist.

Use c15t with Solid

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

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:

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