Skip to main content

SvelteKit Customization

Headless

Choose how much to replace

Try customization first: tokens, slots, presentation and translations change most banners without new markup. Go headless when you need markup the stock components cannot produce.

GoalApproach
Keep the markup, drop c15t's stylesSet noStyle on the provider or on one component, and style the data-* attributes and slot classes.
Your own banner, stock dialoggetHeadlessConsent() for the banner, ConsentDialog for preferences. This page's example.
Your own dialog tooAdd 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:

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:

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.

MemberPurpose
banner.isVisible, dialog.isVisibleWhether that surface is due now.
banner.actionGroups, banner.orderedActionsThe allowed actions, grouped or flat, in the order the policy asks for.
banner.requiredActions, banner.primaryActionsActions you must show, and those to emphasize.
banner.variant, banner.position, banner.blockingThe 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():

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

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 has the full list.