Skip to main content

Next.js Components

ConsentDialog

Open the preference center

ConsentDialog is the modal preference center. It is a Client Component that a Server Component layout can render, inside the ConsentRoot that already provides the consent runtime, as in the App Router and Pages Router setups. Mount it once, next to the banner; it opens whenever something makes dialog the active surface: the banner's Customize button, a ConsentDialogLink in your footer, a ConsentDialogTrigger, or your own code.

app/layout.tsx
import {
	ConsentBanner,
	ConsentDialog,
	ConsentDialogLink,
	ConsentRoot,
} from 'c15t/next';
import { resolveConsent } from 'c15t/next/server';
import type { ReactNode } from 'react';

import './globals.css';

const RootLayout = ({ children }: { children: ReactNode }) => (
	<html lang="en">
		<body>
			{/* Not awaited: the page renders while consent resolves. */}
			<ConsentRoot state={resolveConsent()}>
				{children}
				<ConsentBanner />
				<ConsentDialog />
				<footer>
					<ConsentDialogLink>Privacy settings</ConsentDialogLink>
				</footer>
			</ConsentRoot>
		</body>
	</html>
);

export default RootLayout;

Pass props on that element, for example <ConsentDialog hideBranding />.

The dialog renders nothing on the server. It mounts through a portal after hydration, so it does not change the server HTML or the streamed shell. If the visitor owes a choice or notice, routes that await resolveConsent can render the banner on the server. Pages Router pages without getServerSideProps show it after browser policy resolution. No banner appears when the policy requires no prompt or an existing record already satisfies it.

To open it from another Client Component inside the boundary, set the active surface:

components/privacy-menu-item.tsx
'use client';

import { useSetActiveUI } from 'c15t/next';

export function PrivacyMenuItem() {
	const setActiveUI = useSetActiveUI();
	return (
		<button type="button" onClick={() => setActiveUI('dialog')}>
			Cookie preferences
		</button>
	);
}

ConsentDialogLink does the same with the policy's rights exposed for styling; see ConsentDialogLink. For a floating button, see ConsentDialogTrigger. Under an IAB policy, mount IABConsentDialog from the IAB guide instead; this dialog stays closed for the iab model.

Props

PropTypeDefaultDescription
openbooleanfollows the active surfaceControls the open state. When set, Escape and the dialog's own Save Settings, Accept All and Reject All buttons no longer close it; change the prop instead. A missing policy or an unlisted model still keeps it closed.
showTriggerboolean | ConsentDialogTriggerPropsfalseRenders a floating ConsentDialogTrigger next to the dialog. Pass an object to configure that trigger.
modelsModel[]['opt-in', 'opt-out', 'none']Policy models the dialog responds to. Under a model that is not listed, such as iab, it stays closed.
legalLinks(keyof LegalLinks)[] | nullnoneWhich of the links configured in the provider options.legalLinks render after the description. Omitting the prop renders none. null hides them.
hideBrandingbooleanfalseHides the "Secured by" tag in the card.
uiSourcestring'dialog'Source identifier recorded with saves made from this dialog.
scrollLock, trapFocusbooleanfrom blockingDeprecated. Either one set to false makes the dialog non-blocking, unless the provider sets presentation.preferences.blocking explicitly, which wins. Prefer the provider option.
disableAnimationbooleanfalseSkips the enter and exit animation of the backdrop and the card.
noStylebooleanfalseRemoves the built-in styling from every part.

Behavior

The dialog is a modal wrapper around the preference center. Its card has a header with the title consentManagerDialog.title and the description consentManagerDialog.description followed by the legal links, then the same category accordion and Reject All, Accept All and Save Settings actions that ConsentWidget renders, then the branding tag. It mounts through a portal into document.body after hydration, so it is never part of the server HTML.

Without an open prop, the dialog follows the active surface and opens while it is dialog. These set that surface:

  • The Customize button on a ConsentBanner, and the "Manage preferences" or "Do not sell or share my data" button a notice renders.
  • ConsentDialogLink and ConsentDialogTrigger.
  • The button in a ConsentGate placeholder.
  • useSetActiveUI()('dialog') in your own component, or openDialog() from useHeadlessConsentUI() on the headless subpath.

The component code is split into its own chunk, so it is not part of the first page load. It renders nothing until that chunk is ready, and the chunk downloads at the first of these:

  • The browser's first idle period after the page's load event, while the banner is shown or a ConsentDialogLink, ConsentDialogTrigger, ConsentGate button or other button that opens the dialog is mounted. This covers a visitor who taps or presses Enter with no hover or focus first. Browsers without requestIdleCallback, such as Safari, wait 200 ms after load instead.
  • Hover or focus on a button that opens the dialog.
  • The dialog opening.

Passing open={true} or showTrigger loads it on mount. A visit with saved consent and nothing on the page that opens the dialog never downloads it.

Idle loading is skipped when the visitor has Save-Data on (navigator.connection.saveData), on a 2g or slow-2g connection, and while offline; hover and focus still load it. To load the chunk only on hover, focus or open, set preloadDialog: 'intent' in the provider options. The default is 'idle'. With Next.js, pass it through ConsentRoot's options prop.

If a preload fails, for example because the visitor is offline, nothing is cached: the next hover, focus or open tries the download again.

The dialog's CSS travels in the same chunk and is applied before the dialog renders, so the first page load carries only the banner's rules.

Without an open prop, Escape closes the dialog without saving. If the policy still owes a choice, the banner comes back. Save Settings, Accept All and Reject All close it as soon as the choice is recorded in the browser, in the same task as the click; the exit animation still plays. The request to the backend runs afterwards and never reopens the dialog: a failed request keeps the choice, reports through onError and is retried later. See when a choice is saved for the order of events. A draft that went stale because the policy changed keeps the dialog open for review. With open={true}, these actions leave the dialog visible; the parent must set open={false} to close it. Clicking the backdrop does not close it. Closing discards toggles that were not saved: the next open starts from the recorded choice again. After a save from an uncontrolled dialog, every consent surface hides unless the policy still owes a prompt, in which case the banner returns.

The preference center is blocking by default: a backdrop, body scroll lock and focus trap, all as one value. Set presentation.preferences.blocking to false in the provider options to remove all three; the deprecated scrollLock and trapFocus props do the same for one dialog. variant and position are prompt options. Setting them under presentation.preferences logs an invalid-variant diagnostic in development and changes nothing; the dialog is always centered.

The dialog never opens without a resolved policy rule, even when the active surface is already dialog; it appears as soon as a rule resolves, without a remount. A rule with model: 'none' and no rights owes no consent UI, so the dialog stays closed under it. When such a rule lists any right, for example rights: ['disclosure'] or rights: ['preferences'], the dialog can open as a settings route and Save completes without writing a consent record.

Copy comes from these translation keys: consentManagerDialog.title, consentManagerDialog.description, common.acceptAll, common.rejectAll, common.save, and consentTypes.<category>.title and .description for each row.

Accessibility

The panel carries role="dialog", aria-labelledby="consent-dialog-title" and aria-describedby="consent-dialog-description", plus aria-modal="true" while it is blocking. Its dir attribute follows the active language.

While blocking, focus moves on open to the first tabbable control in the panel, so a keyboard user sees the focus ring on a control rather than around the whole panel; the aria-labelledby and aria-describedby wiring means a screen reader still announces the title and description as focus enters. Tab and Shift+Tab wrap inside the panel, and on close focus returns to the element that opened the dialog. A non-blocking dialog manages no focus: nothing moves focus into the panel or back to the opener. The backdrop is aria-hidden and is not focusable.

Each category switch has the category title as its accessible name. The necessary switch is disabled and always on. A saved grant that the current policy or a privacy signal overrides gets a note under its row, linked to the switch through aria-describedby.

Composition

Every part is available as ConsentDialog.<Part>: Root, Overlay, Card, Header, HeaderTitle, HeaderDescription, Content, Footer and ConsentCustomizationCard, the stock card. Root provides the portal, open state, focus trap, scroll lock and backdrop, and accepts open, models, noStyle, disableAnimation, scrollLock, trapFocus, uiSource and overlay. Pass overlay={false} to render no backdrop, or a node to replace the built-in one.

<ConsentDialog.Root>
  <ConsentDialog.Card>
    <ConsentDialog.Header>
      <ConsentDialog.HeaderTitle>Your privacy choices</ConsentDialog.HeaderTitle>
      <ConsentDialog.HeaderDescription legalLinks={['privacyPolicy']} />
    </ConsentDialog.Header>
    <ConsentDialog.Content>
      <ConsentWidget />
    </ConsentDialog.Content>
    <ConsentDialog.Footer hideBranding />
  </ConsentDialog.Card>
</ConsentDialog.Root>

Keep ConsentWidget inside Content: it owns the draft, the switches and the policy actions, and inherits the dialog's uiSource. Footer renders the branding tag unless you pass children or hideBranding.

The positioner carries data-slot="dialog-positioner", and both it and the panel carry data-blocking="true" while blocking. Provider component slots for the stock structure are dialog.root, dialog.container, dialog.card, dialog.header, dialog.title, dialog.content, dialog.overlay, description.dialog, manager.footer and tag.dialog.

ConsentDialog from the package root and from the c15t/react/consent-dialog subpath is the deferred component described under Behavior, and each ConsentDialog.<Part> loads with the same chunk. c15t/react/components/consent-dialog exports the dialog without the deferral, plus each part as a named export (Card, Header, Overlay, Root and the rest). Importing from that path puts the dialog's code in the bundle of every page that imports it, so the first open needs no download but every visitor downloads the dialog.

The compound parts import from c15t/next as properties of ConsentDialog, and ConsentWidget from the same module. Server Components cannot render them: keep the composition in a file with 'use client'. See Customize for slots and tokens.

Verify

Use the default blocking, uncontrolled dialog and an optional in-scope category that is not restricted by policy or privacy signals such as GPC. Open the dialog from the banner's Customize button or a preferences link. A centered card appears over a dimmed backdrop, the page behind it stops scrolling, and Tab stays inside the card. Press Escape: the card closes and focus returns to the button you used. Open it again, turn a category on and choose Save. The dialog closes and useConsent('<category>') reports true in your components; under a choice prompt the banner does not return, while a notice prompt keeps its banner until it is acknowledged. Reload the page and reopen the dialog: the switch reflects the saved choice.