---
title: Headless
description: Replace the c15t banner or preference dialog with your own Svelte
  markup while the provider keeps policy, storage and script loading.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Choose how much to replace

Try [customization](/docs/customization/overview) first: tokens, slots,
`presentation` and translations change most banners without new markup. Go
headless when you need markup the stock components cannot produce.

|Goal|Approach|
|--|--|
|Keep the markup, drop c15t's styles|Set `noStyle` on the provider or on one component, and style the `data-*` attributes and slot classes.|
|Your own banner, stock dialog|`getHeadlessConsent()` for the banner, `ConsentDialog` for preferences. This page's example.|
|Your own dialog too|Add `getConsentManager()` for the category list and the draft.|

The provider, scripts, `ConsentGate` and the stored choice work the same with
any of them. You only replace the markup.

## Build a banner with getHeadlessConsent

`getHeadlessConsent()` returns the banner and dialog state and one method per
action. Call it at the top level of a component inside
`ConsentProvider`.

This banner renders the actions the policy allows, grouped and ordered the way
the policy asks, and hides itself when no banner is due:

```svelte title="src/lib/custom-consent-banner.svelte"
<script lang="ts">
	import { getHeadlessConsent } from '@c15t/svelte';
	import type { PresentationAction } from '@c15t/svelte';

	const consent = getHeadlessConsent();

	const labels: Record<PresentationAction, string> = {
		accept: 'Accept all',
		customize: 'Customize',
		dismiss: 'Got it',
		reject: 'Reject all',
		save: 'Save',
	};
</script>

{#if consent.banner.isVisible}
	<section class="custom-consent-banner" aria-label="Cookie consent">
		<p>
			We use cookies to measure traffic and show ads. Choose which ones can run.
		</p>
		<!-- The policy decides which actions this visitor gets, and in what order. -->
		{#each consent.banner.actionGroups as group, index (index)}
			<div>
				{#each group as action (action)}
					<button type="button" onclick={() => consent.performAction(action)}>
						{labels[action]}
					</button>
				{/each}
			</div>
		{/each}
	</section>
{/if}

<style>
	.custom-consent-banner {
		position: fixed;
		inset: auto 1rem 1rem;
		max-width: 32rem;
		padding: 1rem;
		border: 1px solid currentColor;
		border-radius: 0.5rem;
		background: white;
	}
	.custom-consent-banner div {
		display: flex;
		gap: 0.5rem;
		margin-top: 0.5rem;
	}
</style>
```

Render it where the stock `ConsentBanner` would go. The stock dialog still
handles Customize and the preferences link, so keep `ConsentDialog`. It adds
its own rules when it opens:

```svelte title="src/routes/+layout.svelte"
<script lang="ts">
	import {
		ConsentDialog,
		ConsentDialogLink,
		ConsentProvider,
		hosted,
	} from '@c15t/svelte';

	import CustomConsentBanner from '#lib/custom-consent-banner.svelte';
	import { scripts } from '#lib/scripts.js';

	let { children } = $props();

	const mode = hosted({ backendURL: 'https://your-project.inth.app' });
</script>

<ConsentProvider {mode} {scripts}>
	{@render children()}
	<footer>
		<ConsentDialogLink>Privacy settings</ConsentDialogLink>
	</footer>
	<CustomConsentBanner />
	<ConsentDialog />
</ConsentProvider>
```

In a Svelte app without SvelteKit, the same markup goes in the component that
renders the provider.

|Member|Purpose|
|--|--|
|`banner.isVisible`, `dialog.isVisible`|Whether that surface is due now.|
|`banner.actionGroups`, `banner.orderedActions`|The allowed actions, grouped or flat, in the order the policy asks for.|
|`banner.requiredActions`, `banner.primaryActions`|Actions you must show, and those to emphasize.|
|`banner.variant`, `banner.position`, `banner.blocking`|The resolved shape, position and blocking behavior, if you want to follow them.|
|`activeUI`|`'banner'`, `'dialog'` or `'none'`.|
|`performAction(action)`|`accept`, `reject`, `dismiss`, `save` or `customize`. Customize opens the dialog.|
|`openBanner()`, `openDialog()`, `closeUI()`|Show or hide a surface.|
|`saveCustomPreferences()`|Record the dialog's draft.|

Render from `actionGroups` or `orderedActions` rather than a fixed list. A
policy can require a reject button next to accept, or offer only a dismiss
button for a notice, and a hard-coded pair of buttons breaks those rules.

## Build your own preferences

For a custom dialog or settings page, read the category list and the unsaved
draft from `getConsentManager()`:

```svelte title="src/lib/cookie-preferences.svelte"
<script lang="ts">
	import { getConsentManager } from '@c15t/svelte';

	const consent = getConsentManager();
	const config = $derived(consent.translationConfig);
	const copy = $derived(config.translations[config.defaultLanguage ?? 'en']);
</script>

{#if consent.draft.isStale}
	<p role="alert">The cookie policy changed. Review your choices.</p>
{/if}
<fieldset>
	<legend>Cookie preferences</legend>
	{#each consent.getDisplayedConsents() as category (category.name)}
		<label>
			<input
				type="checkbox"
				checked={consent.selectedConsents[category.name] ?? false}
				disabled={category.name === 'necessary'}
				onchange={(event) =>
					consent.setConsent(category.name, event.currentTarget.checked)}
			/>
			{copy?.consentTypes?.[category.name]?.title ?? category.name}
		</label>
	{/each}
</fieldset>
<button
	type="button"
	disabled={consent.draft.isStale}
	onclick={() => consent.saveConsents('custom')}
>
	Save preferences
</button>
```

`setConsent` changes the draft only. `saveConsents('custom')` records it. If
the policy changes while the draft is open, `consent.draft.isStale` becomes
`true` and saving throws, so the example disables Save and shows a message.
Translated titles and descriptions are in `consent.translationConfig`.
[Context getters](./getters#work-with-the-draft) covers the draft.

`@c15t/svelte` exports `Dialog`, `Switch`, `Accordion`, `PreferenceItem` and
the `focusTrap`, `scrollLock` and `portal` actions if you want accessible
building blocks for the dialog itself; see [primitives](./components/primitives).

## Verify a custom UI

Run the same checks as the stock components: reject, reload and confirm no
optional vendor requests; allow one category and confirm only its vendors
load; reopen preferences from your footer link. Test keyboard focus and a
screen reader on your markup, because c15t no longer owns it.
[Verify consent](/docs/guides/verify-consent) has the full list.
