Skip to main content

TanStack Start Customization

Headless

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:

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.