Skip to main content

HTML Customization

Headless

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

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

FieldWhat it tells your UI
promptRequirement.kind'choice' needs a decision, 'notice' needs only an acknowledgement, 'none' needs no prompt.
policyRule.actions.requiredThe buttons the policy requires on the first prompt, such as accept and reject.
policyRule.actions.allowedEvery button the policy allows there.
policyRule.rightsRights the visitor has, such as preferences and opt-out.
policyRule.scopeThe categories the policy covers.
effectivePermissionsWhether each category is allowed right now.
explicitChoiceWhat 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 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 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:

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