---
title: Building Headless Components
description: Build policy-aware custom consent components in Next.js using the headless hooks and policy-pack tooling.
---
Building custom consent UI is easier now because c15t exposes multiple layers of policy-aware primitives instead of forcing you to reconstruct banner rules by hand.

Think of customization as a ladder:

* stock component props for the shortest path
* `ConsentBanner.PolicyActions` and `ConsentWidget.PolicyActions` when you want custom structure but still want c15t to resolve policy-aware actions
* `useHeadlessConsentUI()` when you need fully manual action rendering, custom controls, or non-standard flow

> ⚠️ **Warning:**
> Headless is the last step in the customization ladder. Use this guide only when pre-built components, tokens, slots, compound components, and noStyle are no longer sufficient.

The headless stack underneath that is:

* `useHeadlessConsentUI()` for policy-aware banner/dialog actions, ordering, layout, and primary actions hints
* `@c15t/ui/utils` for the pure policy-action helpers that framework packages build on
* `useConsentManager()` for runtime state, categories, selected consent state, and policy metadata
* `useTranslations()` for the resolved copy
* `offlinePolicy.policyPacks` for offline previews that behave like backend policy resolution

The split is intentional: `@c15t/ui` owns pure policy-action resolution, while the framework hooks own visibility, consent mutations, and reactive state.

> ℹ️ **Info:**
> This guide is about building your own components while still respecting resolved policy-pack behavior. For the general headless overview, see Headless Mode.

## Choose the Smallest Layer That Solves the Job

Start with the smallest API surface that still gives you the behavior you need:

* Stay with stock components when you only need theming, spacing, copy, or legal-link changes
* Use `ConsentBanner.PolicyActions` or `ConsentWidget.PolicyActions` when you want a custom compound-component layout but still want grouped actions, ordering, and primary emphasis to come from policy
* Add `renderAction` when the grouping is still correct but you want to remap actions to stock c15t button compounds
* Reach for `useHeadlessConsentUI()` only when you need custom button elements, need to map `actionGroups` yourself, wire non-button controls, or coordinate the consent UI with a more custom state machine

This order matters because every step down the ladder gives you more control, but also makes it easier for your UI to drift away from the resolved policy if you stop using the provided state.

## Before You Build Headless UI

Do not use headless mode for problems that are still inside the stock component model:

* Use `layout`, `direction`, `primaryButton`, and `legalLinks` before you rebuild banner markup
* Use `theme.consentActions` before you swap out stock actions
* Use tokens such as `colors.surface` and `colors.surfaceHover` before raw CSS overrides
* Use slots such as `consentBannerCard`, `consentBannerFooter`, and `consentDialogCard` before compound components
* Use `ConsentManagerProvider.options.i18n` before rebuilding UI just to change text

A good rule: if the stock banner or dialog structure is still correct, you probably do not need headless mode.

## What the Headless Tooling Gives You

The main win is that your custom UI can stay aligned with policy packs without duplicating policy logic in your components.

`useHeadlessConsentUI()` already resolves:

* which actions are allowed
* the order those actions should render in
* grouped actions from policy `layout`
* layout `direction` (`row` or `column`)
* the primary actions
* UI profile and scroll-lock hints
* whether the banner or dialog should currently be visible

The hook also gives you the policy-aware action helpers you are expected to call:

* `performBannerAction('accept' | 'reject')`
* `performDialogAction('accept' | 'reject')`
* `saveCustomPreferences()` for the dialog `customize` action
* `openDialog()`, `openBanner()`, and `closeUI()` for surface visibility

That means your component mostly focuses on markup and design-system concerns instead of re-implementing policy interpretation.

For most compound-component layouts, start with `ConsentBanner.PolicyActions` or `ConsentWidget.PolicyActions`. They render stock c15t buttons and translations by default, and `renderAction` is only needed when you want to override which stock compound renders for each action. Reach for manual `actionGroups` mapping when you need action rendering that no longer fits the stock button compounds.

## Policy-Aware Compound Components First

If your goal is "custom layout, same policy behavior", start here before dropping to manual `actionGroups` rendering:

```tsx title="components/consent-manager/banner-shell.tsx"
'use client';

import { ConsentBanner } from '@c15t/nextjs';

export function BannerShell() {
  return (
    <ConsentBanner.Root>
      <ConsentBanner.Card>
        <ConsentBanner.Header>
          <ConsentBanner.Title />
          <ConsentBanner.Description />
        </ConsentBanner.Header>
        <ConsentBanner.PolicyActions />
      </ConsentBanner.Card>
    </ConsentBanner.Root>
  );
}
```

Use `renderAction` only when you want to remap actions to stock button compounds while keeping the same policy-driven grouping and ordering:

```tsx title="components/consent-manager/banner-actions.tsx"
'use client';

import { ConsentBanner } from '@c15t/nextjs';

export function BannerActionsWithCustomMapping() {
  return (
    <ConsentBanner.PolicyActions
      renderAction={(action, props) => {
        const { key, ...buttonProps } = props;

        switch (action) {
          case 'accept':
            return <ConsentBanner.AcceptButton key={key} {...buttonProps} />;
          case 'reject':
            return <ConsentBanner.RejectButton key={key} {...buttonProps} />;
          case 'customize':
            return <ConsentBanner.CustomizeButton key={key} {...buttonProps} />;
        }
      }}
    />
  );
}
```

> ℹ️ **Info:**
> For custom layouts built from c15t compound components, prefer ConsentBanner.PolicyActions and ConsentWidget.PolicyActions. The examples below intentionally use manual actionGroups mapping to show the fully headless escape hatch.

## Provider Setup for Local Policy Testing

```tsx title="components/consent-manager/provider.tsx"
'use client';

import type { ReactNode } from 'react';
import {
  ConsentManagerProvider,
  policyPackPresets,
} from '@c15t/nextjs';

export function ConsentManager({ children }: { children: ReactNode }) {
  return (
    <ConsentManagerProvider
      options={{
        mode: 'offline',
        offlinePolicy: {
          policyPacks: [
            policyPackPresets.californiaOptOut(),
            policyPackPresets.europeOptIn(),
            policyPackPresets.worldNoBanner(),
          ],
        },
        overrides: {
          country: 'GB',
        },
      }}
    >
      {children}
    </ConsentManagerProvider>
  );
}
```

## Policy-Aware Banner Example

```tsx title="components/consent-manager/custom-banner.tsx"
'use client';

import { useHeadlessConsentUI, useTranslations } from '@c15t/nextjs/headless';

export function CustomConsentBanner() {
  const { banner, openDialog, performBannerAction } = useHeadlessConsentUI();
  const translations = useTranslations();

  function getActionLabel(action: (typeof banner.allowedActions)[number]) {
    switch (action) {
      case 'accept':
        return translations.common.acceptAll;
      case 'reject':
        return translations.common.rejectAll;
      case 'customize':
        return translations.common.customize;
    }
  }

  if (!banner.isVisible) return null;

  return (
    <aside className="rounded-xl border bg-white p-6 shadow-lg">
      <h2 className="text-lg font-semibold">{translations.cookieBanner.title}</h2>
      <p className="mt-2 text-sm text-gray-600">
        {translations.cookieBanner.description}
      </p>

      <div className="mt-4 space-y-2">
        {banner.actionGroups.map((group, index) => (
          <div key={`${group.join('-')}-${index}`} className="flex gap-2">
            {group.map((action) => (
              <button
                key={action}
                type="button"
                className={banner.primaryActions.includes(action) ? 'btn-primary' : 'btn-secondary'}
                onClick={() => {
                  if (action === 'customize') {
                    openDialog();
                    return;
                  }
                  void performBannerAction(action);
                }}
              >
                {getActionLabel(action)}
              </button>
            ))}
          </div>
        ))}
      </div>
    </aside>
  );
}
```

## Category List That Respects the Resolved Policy

```tsx title="components/consent-manager/custom-dialog.tsx"
'use client';

import {
  useConsentManager,
  useHeadlessConsentUI,
  useTranslations,
} from '@c15t/nextjs/headless';

export function CustomConsentDialog() {
  const { dialog, performDialogAction, saveCustomPreferences } = useHeadlessConsentUI();
  const {
    consentTypes,
    consentCategories,
    consents,
    selectedConsents,
    setSelectedConsent,
  } = useConsentManager();
  const translations = useTranslations();

  function getActionLabel(action: (typeof dialog.allowedActions)[number]) {
    switch (action) {
      case 'accept':
        return translations.common.acceptAll;
      case 'reject':
        return translations.common.rejectAll;
      case 'customize':
        return translations.common.save;
    }
  }

  if (!dialog.isVisible) return null;

  const displayedTypes = consentTypes.filter(
    (type) => type.display && consentCategories.includes(type.name)
  );

  return (
    <section className="rounded-xl border bg-white p-6 shadow-xl">
      <h2 className="text-lg font-semibold">
        {translations.consentManagerDialog.title}
      </h2>

      <div className="mt-4 space-y-3">
        {displayedTypes.map((type) => (
          <label key={type.name} className="flex items-start justify-between gap-4">
            <div>
              <p className="font-medium">
                {translations.consentTypes[type.name]?.title ?? type.name}
              </p>
              <p className="text-sm text-gray-600">{type.description}</p>
            </div>
            <input
              type="checkbox"
              checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
              disabled={type.disabled}
              onChange={(event) =>
                setSelectedConsent(type.name, event.target.checked)
              }
            />
          </label>
        ))}
      </div>

      <div className="mt-4 space-y-2">
        {dialog.actionGroups.map((group, index) => (
          <div key={`${group.join('-')}-${index}`} className="flex gap-2">
            {group.map((action) => (
              <button
                key={action}
                type="button"
                onClick={() => {
                  if (action === 'customize') {
                    void saveCustomPreferences();
                    return;
                  }
                  void performDialogAction(action);
                }}
              >
                {getActionLabel(action)}
              </button>
            ))}
          </div>
        ))}
      </div>
    </section>
  );
}
```

## What Headless Is Not For

Headless mode is not the recommended path for:

* changing the banner footer background
* rounding the stock banner card
* restyling stock banner or dialog buttons
* changing consent copy

Those should stay in the pre-built stack with tokens, slots, `theme.consentActions`, and provider `i18n`.

## What a Policy-Aware Headless Component Should Respect

When you build custom banner or dialog components, make sure they use:

* `activeUI` or `banner.isVisible` / `dialog.isVisible` for visibility
* `allowedActions`, `actionGroups`, and `primaryActions` instead of hard-coding buttons
* `primaryActions` for visual emphasis
* `consentCategories` when deciding which category toggles to render
* `policyDecision` when you want to debug why a specific UI state was chosen

If you ignore those values, your custom UI can drift away from the resolved policy pack even though the underlying consent engine is configured correctly.

## Test Custom UI Against the Resolved Policy

```ts
import {
  getEffectivePolicy,
  type PolicyUIState,
  validateUIAgainstPolicy,
} from 'c15t';

const policy = getEffectivePolicy(initData);

const dialogState: PolicyUIState = {
  mode: 'dialog',
  actions: ['accept', 'reject', 'customize'],
  layout: 'split',
  uiProfile: 'compact',
  scrollLock: true,
};

const issues = validateUIAgainstPolicy({
  policy,
  state: dialogState,
});

expect(issues).toEqual([]);
```

## Validation and Testing

If you are building a reusable headless component library, validate your rendered UI against the resolved runtime policy in tests.

The core package exposes:

* `getEffectivePolicy(initData)` to read the resolved policy from `/init`
* `validateUIAgainstPolicy({ policy, state })` to detect mismatches such as wrong actions, layout, or mode

This is useful when your design system renders custom button arrangements and you want tests to catch policy drift early.

> ℹ️ **Info:**
> Pair this with Policy Packs when you want to exercise multiple regional UI states locally before wiring a live backend.
