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.
| 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. |
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
@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:
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:
| 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:
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:
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 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:
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 fornotice, and nothing fornone. 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
resolveConsentPresentationreturns, 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()anddismissNotice()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.translationscarries 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:
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:
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.
- Your banner appears once the policy resolves, with the actions
resolveConsentPresentationreturned. Vendor requests are absent. - Reject and reload. Your banner stays hidden and vendors stay blocked.
- Open your preferences, allow Measurement only and save. Measurement vendors load and marketing vendors do not.
- Tab through your banner and dialog. Every control is reachable, and Escape closes the dialog.