---
title: Headless
description: Render your own consent banner and preferences in plain HTML with
  c15t.headless.js, the ui event and data-c15t-action buttons, while c15t keeps
  resolving the policy, storing choices and gating scripts.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## When to go headless

Load `c15t.headless.js` when theme tokens and CSS cannot give you the banner
you want, for example a bar that matches your site's markup exactly. The
headless build keeps everything except the UI:

* policy resolution, stored choices and backend saves;
* gated scripts, gated iframes and the network blocker;
* `window.c15t`, its events and the `data-c15t-action` page hooks.

It ships no banner, no preference dialog, no trigger and no CSS. Before you
switch, check whether [customize](/docs/frameworks/html/customize) covers the
change, because a custom UI takes on every duty the stock one handles.

## Build a bottom bar

This block replaces the stock tag. It loads the headless build, then renders a
bar with Accept, Reject, Preferences and Save controls. The accept, reject,
customize and dismiss buttons are `data-c15t-action` buttons, so they need no
code. One `ui` listener shows the bar, switches it into preferences mode and
swaps the buttons for a notice:

```html title="index.html"
<script
	src="https://your-project.inth.app/c15t.headless.js"
	defer
></script>

<style>
	.cookie-bar {
		position: fixed;
		inset: auto 0 0;
		z-index: 50;
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: 0.75rem 1.5rem;
		padding: 0.875rem 1.5rem;
		background: #14213d;
		color: #f8f9fb;
		font:
			0.9375rem/1.5 system-ui,
			sans-serif;
		box-shadow: 0 -4px 16px rgb(0 0 0 / 0.15);
	}

	.cookie-bar__text {
		flex: 1 1 20rem;
		margin: 0;
	}

	.cookie-bar__actions {
		display: flex;
		flex-wrap: wrap;
		gap: 0.5rem;
	}

	.cookie-bar button {
		padding: 0.5rem 1rem;
		border: 1px solid #f8f9fb;
		border-radius: 999px;
		background: transparent;
		color: inherit;
		font: inherit;
		cursor: pointer;
	}

	.cookie-bar .cookie-bar__button {
		background: #f8f9fb;
		color: #14213d;
		font-weight: 600;
	}

	.cookie-bar .cookie-bar__link {
		border-color: transparent;
		text-decoration: underline;
		text-underline-offset: 3px;
	}

	.cookie-bar button:focus-visible {
		outline: 2px solid #fca311;
		outline-offset: 2px;
	}
	/* On narrow screens, Reject and Accept share a row at equal width. */
	@media (max-width: 30rem) {
		.cookie-bar__actions {
			width: 100%;
		}

		.cookie-bar__actions button {
			flex: 1 1 40%;
		}

		.cookie-bar__actions .cookie-bar__link {
			flex-basis: 100%;
		}
	}

	/* The headless build has no dialog, so preferences open inside the bar. */
	.cookie-bar[hidden],
	.cookie-bar__prefs[hidden] {
		display: none;
	}

	.cookie-bar__prefs {
		display: flex;
		flex-wrap: wrap;
		gap: 0.5rem 1.25rem;
		flex-basis: 100%;
		margin: 0;
		padding: 0;
		border: 0;
	}

	.cookie-bar__prefs label {
		display: flex;
		align-items: center;
		gap: 0.375rem;
	}
</style>

<section
	id="cookie-bar"
	class="cookie-bar"
	aria-label="Cookie consent"
	hidden
>
	<p class="cookie-bar__text">
		We use cookies to measure traffic and improve this site. Choose which
		ones can run.
	</p>
	<fieldset
		class="cookie-bar__prefs"
		hidden
	>
		<legend class="cookie-bar__text">Allow these cookies:</legend>
		<label
			><input
				type="checkbox"
				name="functionality"
			/>
			Functionality</label
		>
		<label
			><input
				type="checkbox"
				name="measurement"
			/>
			Measurement</label
		>
		<label
			><input
				type="checkbox"
				name="experience"
			/>
			Experience</label
		>
		<label
			><input
				type="checkbox"
				name="marketing"
			/>
			Marketing</label
		>
	</fieldset>
	<div class="cookie-bar__actions">
		<!-- c15t wires data-c15t-action buttons itself; they need no code. -->
		<button
			type="button"
			class="cookie-bar__link"
			data-c15t-action="customize"
		>
			Preferences
		</button>
		<button
			type="button"
			class="cookie-bar__button"
			data-c15t-action="dismiss"
			data-notice-only
			hidden
		>
			Got it
		</button>
		<button
			type="button"
			class="cookie-bar__button"
			data-c15t-action="reject"
			data-choice-only
		>
			Reject all
		</button>
		<button
			type="button"
			class="cookie-bar__button"
			data-c15t-action="accept"
			data-choice-only
		>
			Accept all
		</button>
		<button
			type="button"
			class="cookie-bar__button"
			data-save
			hidden
		>
			Save
		</button>
	</div>
</section>

<script>
	const bar = document.getElementById('cookie-bar');
	const prefs = bar.querySelector('.cookie-bar__prefs');
	const saveButton = bar.querySelector('[data-save]');
	const switches = prefs.querySelectorAll('input');
	const choiceButtons = bar.querySelectorAll('[data-choice-only]');
	const noticeButtons = bar.querySelectorAll('[data-notice-only]');

	// The tag is deferred, so queue calls until window.c15t is the real API.
	window.c15t = window.c15t || [];
	const queue = (call) => {
		if (Array.isArray(window.c15t)) {
			window.c15t.push(call);
		} else {
			window.c15t[call[0]](...call.slice(1));
		}
	};

	// c15t says which surface is due; the page decides what it looks like.
	// 'banner' shows the bar, 'dialog' opens the switches, 'none' hides it.
	queue([
		'on',
		'ui',
		(surface) => {
			const { kind } = c15t.getSnapshot().promptRequirement;
			const editing = surface === 'dialog';
			// A notice gets one acknowledgement button instead of Accept and Reject.
			for (const button of choiceButtons) {
				button.hidden = kind === 'notice' && !editing;
			}
			for (const button of noticeButtons) {
				button.hidden = kind !== 'notice' || editing;
			}
			for (const input of switches) {
				input.checked = c15t.has(input.name);
			}
			prefs.hidden = !editing;
			saveButton.hidden = !editing;
			bar.hidden =
				surface === 'none' || (surface === 'banner' && kind === 'none');
		},
	]);

	// Save records exactly what the switches show.
	saveButton.addEventListener('click', () => {
		const choice = {};
		for (const input of switches) {
			choice[input.name] = input.checked;
		}
		c15t.save(choice);
	});
</script>
```

Put the block at the end of `<body>`.

## Follow the surface c15t wants shown

c15t still decides which surface should show. The `ui` event reports it:

|`ui` payload|What your page shows|
|--|--|
|`'banner'`|The first prompt. Read `promptRequirement.kind` to choose between a choice and a notice.|
|`'dialog'`|Your preferences view. A `data-c15t-action="customize"` button, a `#c15t-preferences` link or `c15t.openDialog()` asked for it.|
|`'none'`|Nothing. Hide the bar.|

A `ui` listener added after the policy resolved runs at once with the current
surface, so a late script does not miss the first banner. Your own controls
change the surface with `c15t.showBanner()`, `c15t.openDialog()` and
`c15t.closeDialog()`, or the matching `data-c15t-action` values. None of them
records anything.

## Read the prompt the policy requires

`c15t.getSnapshot()` returns the fields a custom UI renders from:

|Field|What it tells your UI|
|--|--|
|`promptRequirement.kind`|`'choice'` needs a decision, `'notice'` needs only an acknowledgement, `'none'` needs no prompt.|
|`policyRule.actions.required`|The buttons the policy requires on the first prompt, such as `accept` and `reject`.|
|`policyRule.actions.allowed`|Every button the policy allows there.|
|`policyRule.rights`|Rights the visitor has, such as `preferences` and `opt-out`.|
|`policyRule.scope`|The categories the policy covers.|
|`effectivePermissions`|Whether each category is allowed right now.|
|`explicitChoice`|What the visitor recorded, if anything.|

Use `c15t.has(category)` to set each switch in your preferences view to the
current permission. Save exactly what the switches show with
`c15t.save({ measurement: true, marketing: false })`.

## 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.** Render every action in
  `policyRule.actions.required`, at equal prominence where the policy asks.
* **A way back.** Keep a [preferences link](/docs/frameworks/html/components/preferences-link)
  on every page after the banner closes.
* **Visitor actions only.** Call `save()`, `acceptAll()`, `rejectAll()` and
  `dismissNotice()` only from a click or key press, never on load.
* **Accessibility.** Focus, keyboard use, labels, error states and narrow
  screens are yours. Use native buttons and a labelled region or dialog.

Read [how consent works](/docs/concepts/how-consent-works) for the difference
between a permission and a recorded choice.

## Keep the stock dialog with your own banner

To replace only the banner, keep `c15t.js` and turn the stock banner off:

```html
<script>
  window.c15t = window.c15t || [];
  c15t.push(['config', { ui: { banner: false } }]);
</script>
```

Your banner then follows the `ui` event for `'banner'`, and its customize
button opens the stock preference dialog.

## Check it works

Open the page in a private window with the Network tab open.

1. The bar appears once the policy resolves, with the buttons your policy
   requires. Vendor requests are absent.
2. Click **Reject all** and reload. The bar stays hidden and vendors stay
   blocked.
3. Click the **Privacy settings** link, turn on Measurement only and click
   **Save**. Measurement vendors load and marketing vendors do not.
4. Tab through the bar. Every control is reachable and labelled.
