---
title: Headless
description: Build a custom consent banner in React with the c15t/react/headless
  hooks inside your ConsentProvider.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## When to go headless

The pre-built banner and dialog cover most designs through props, slots, and
the stylesheet. Go headless when your markup has to be something else
entirely: a design system component, a native sheet, or a layout the compound
parts cannot express.

Headless code owns the rendered controls, so it also owns the compliance
outcome. The hooks hand you the actions a policy requires, the rights it must
keep reachable, and diagnostics when your presentation drops one. Render from
those lists rather than from a fixed set of buttons, and a site that later adds
a notice region keeps working without a code change.

Check for a policy before rendering your own surfaces. `useModel()` from
`c15t/react` returns `null` while no rule has resolved and `'none'` under a
rule that owes no consent UI, and the pre-built surfaces render nothing in that
state.

## Minimal example

The headless hooks read the runtime from the nearest `ConsentProvider` from
`c15t/react`. Render this component inside that provider in place of the stock
banner, and keep the dialog and persistent preferences control.

```tsx title="src/consent-banner.tsx"
import { useHeadlessConsentUI, useTranslations } from 'c15t/react/headless';

const ACTION_LABELS = {
	accept: 'acceptAll',
	customize: 'customize',
	dismiss: 'acknowledge',
	reject: 'rejectAll',
	save: 'save',
} as const;

export const Banner = () => {
	const { banner, performAction, openDialog } = useHeadlessConsentUI();
	const { common, rights } = useTranslations();

	if (!banner.isVisible) {
		return null;
	}

	return (
		<section aria-label="Privacy">
			{banner.preferenceControls.map((right) => (
				<button key={right} type="button" onClick={openDialog}>
					{right === 'opt-out' ? rights?.optOut : rights?.preferences}
				</button>
			))}
			{banner.actionGroups.map((group) => (
				<div key={group.join('-')}>
					{group.map((action) => (
						<button
							key={action}
							type="button"
							data-primary={banner.primaryActions.includes(action) || undefined}
							onClick={() => performAction(action)}
						>
							{common[ACTION_LABELS[action]]}
						</button>
					))}
				</div>
			))}
		</section>
	);
};
```

`banner.actionGroups` is the resolved layout: reject and accept share a group
at equal prominence, and the rest follow. `banner.orderedActions` is the same
list flattened. Under a notice the only action is `dismiss`, and
`banner.preferenceControls` recommends additional buttons for opening
preferences. Under a notice it contains `opt-out`, which selects the
"Do not sell or share my data" label. The example renders that button and
an "OK" button. Both controls keep their own command: opening
preferences and dismissing the notice.

The list is a rendering helper. It does not establish that your UI implements
all policy rights. Provide disclosure and persistent preferences access.

`performAction` saves all categories for `accept`, none for `reject`, the
current draft for `save`, records a dismissal for `dismiss`, and opens the
preference center for `customize`. `banner.diagnostics` reports when a host
layout drops a required action or gives equivalent actions different
prominence. Review each diagnostic when configuring custom presentation.

`banner.variant`, `banner.position`, and `banner.blocking` carry the resolved
shape from `presentation.prompt`, so a headless surface can follow the same
bar, widget, or wall choice the pre-built banner would make, and can trap
focus and lock scroll exactly when `blocking` is true.
