TanStack Start Components
ConsentDialog
Render the preference dialog
ConsentDialog is the modal preference center. Render it once inside
ConsentRoot, next to the banner. It opens when the banner's Customize button,
a ConsentDialogLink, a ConsentDialogTrigger or your own code makes dialog
the active surface:
Under an IAB policy the dialog stays closed; render IABConsentDialog as
described in IAB TCF. Provider options
such as preloadDialog go through ConsentRoot's options prop.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | follows the active surface | Controls 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. |
showTrigger | boolean | ConsentDialogTriggerProps | false | Renders a floating ConsentDialogTrigger next to the dialog. Pass an object to configure that trigger. |
models | Model[] | ['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)[] | null | none | Which of the links configured in the provider options.legalLinks render after the description. Omitting the prop renders none. null hides them. |
hideBranding | boolean | false | Hides the "Secured by" tag in the card. |
uiSource | string | 'dialog' | Source identifier recorded with saves made from this dialog. |
scrollLock, trapFocus | boolean | from blocking | Deprecated. Either one set to false makes the dialog non-blocking, unless the provider sets presentation.preferences.blocking explicitly, which wins. Prefer the provider option. |
disableAnimation | boolean | false | Skips the enter and exit animation of the backdrop and the card. |
noStyle | boolean | false | Removes 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. ConsentDialogLinkandConsentDialogTrigger.- The button in a
ConsentGateplaceholder. useSetActiveUI()('dialog')in your own component, oropenDialog()fromuseHeadlessConsentUI()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
loadevent, while the banner is shown or aConsentDialogLink,ConsentDialogTrigger,ConsentGatebutton 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 withoutrequestIdleCallback, such as Safari, wait 200 ms afterloadinstead. - 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.
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.
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.