---
title: Headless
description: Build your own consent banner markup in a TanStack Start app with
  the c15t/tanstack-start/headless hooks inside ConsentRoot.
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.

## Render your own banner

`c15t/tanstack-start/headless` re-exports the headless hooks from
`c15t/react/headless`. They read the runtime from `ConsentRoot`. Render your
component inside `ConsentRoot` in place of `ConsentBanner`, and keep
`ConsentDialog` and a preferences link:

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

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

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

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

	return (
		<section role="region" 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>
	);
}
```

With an awaited root loader, `banner.isVisible` is already `true` on the server
for a visitor who owes a choice, so your banner is in the first HTML like the
stock one. With a streamed loader or a prerendered page it turns `true` after
hydration.

Check for a policy before rendering other custom surfaces. `useModel()` returns
`null` while no policy has resolved and `'none'` under a policy that owes no
consent UI.

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