---
title: Headless
description: Build your own consent banner and preference form in a Vue app with
  the c15t composables instead of ConsentRoot.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## When to go headless

Tokens, `presentation` and `components` slots change most designs without
new markup. See [customize](/docs/frameworks/vue/customize) first. Go
headless when the consent UI has to be your own markup, such as a design
system component.

Headless changes only the UI. The Vue plugin still resolves the policy,
stores choices, loads your `scripts` and gates iframes.

## Install the plugin as usual

Install `c15tVue` exactly as in the [quickstart](/docs/frameworks/vue/quickstart),
with the Vite plugin and your `scripts`:

```ts title="src/main.ts"
import { c15tVue, manifest } from 'c15t/vue/vue-plugin';
import { createApp } from 'vue';

import App from './App.vue';
import { scripts } from './scripts';

createApp(App).use(c15tVue, { mode: manifest(), scripts }).mount('#app');
```

## Write the consent UI

This component shows a banner while `activeUI` is `'banner'` and a preference
form while it is `'manager'`:

```vue title="src/ConsentPrompt.vue"
<script setup lang="ts">
import {
	useConsentActiveUI,
	useConsentDraft,
	useConsentSave,
} from 'c15t/vue/vue-plugin';

const activeUI = useConsentActiveUI();
const saveConsent = useConsentSave();
const { displayedCategories, save: saveDraft, values } = useConsentDraft();

// Recording a choice satisfies the prompt, so c15t closes the banner itself.
const choose = (choice: 'all' | 'none') => {
	void saveConsent(choice);
};

// Preferences stay open until you close them. c15t records the draft
// locally before the network request, so closing right away is safe.
const savePreferences = () => {
	void saveDraft();
	activeUI.value = null;
};
</script>

<template>
	<section
		v-if="activeUI === 'banner'"
		class="consent-prompt"
		aria-label="Cookie consent"
	>
		<p>We use cookies for measurement and marketing.</p>
		<button type="button" @click="choose('none')">Reject all</button>
		<button type="button" @click="choose('all')">Accept all</button>
		<button type="button" @click="activeUI = 'manager'">Customize</button>
	</section>
	<section
		v-else-if="activeUI === 'manager'"
		class="consent-prompt"
		aria-label="Privacy settings"
	>
		<label v-for="category in displayedCategories" :key="category">
			<input
				v-model="values[category]"
				type="checkbox"
				:disabled="category === 'necessary'"
			/>
			{{ category }}
		</label>
		<button type="button" @click="savePreferences">Save</button>
	</section>
</template>
```

`useConsentSave()` records Accept all or Reject all. Recording a choice
satisfies the prompt, so c15t sets `activeUI` to `null` and the banner closes.
`useConsentDraft()` holds the preference form. `displayedCategories` lists the
categories the policy lets the visitor choose, `values` holds each switch, and
`save()` records them. Preferences stay open after a save, so close them
yourself. [Composables](/docs/frameworks/vue/composables#build-a-preference-form)
lists every field of the draft.

Add focus handling, a close control and styles before you ship this. The
component keeps the bare markup so the consent logic is easy to read.

## Mount it instead of ConsentRoot

Render your component where `ConsentRoot` would go, and keep a way to reopen
preferences:

```vue title="src/App.vue"
<script setup lang="ts">
import { useConsentActiveUI } from 'c15t/vue/vue-plugin';

import ConsentPrompt from './ConsentPrompt.vue';
import HomePage from './HomePage.vue';

const activeUI = useConsentActiveUI();
</script>

<template>
	<ConsentPrompt />
	<HomePage />
	<footer>
		<button type="button" @click="activeUI = 'manager'">
			Privacy settings
		</button>
	</footer>
</template>
```

Setting `activeUI` to `'manager'` opens your preference form. Do not render
`ConsentRoot` as well, or the visitor sees two banners.

## Show the buttons the policy requires

A policy can require buttons and forbid others. For a notice, it requires a
dismiss action instead of Accept and Reject. Read the resolved actions with
`useConsentPolicyActions('prompt')` and render `primaryActions` and
`actionGroups` from it, rather than hard-coding the buttons. Call
`useDismissNotice()` for a notice's dismiss action. Dismissing records an
acknowledgement, not consent.

`usePromptRequirement()` tells you whether the policy asks for a choice, a
notice or nothing.

## Verify the custom UI

Run through [verify consent](/docs/guides/verify-consent) with your
component. Check that Reject all is as easy to reach as Accept all, that the
preference form lists the policy's categories, and that a rejection survives a
reload.
