Skip to main content

TanStack Start Customization

Compose your own banner

When to compose

Compose the banner from its parts when you need its elements in a different order, an element it does not have, or your design system's button in place of c15t's. The parts keep the stock behavior. They read the same policy, record the same choices and render the same class names, so theme tokens and slots still apply.

Try the lighter tools first:

ChangeUse
Colors, type, radius, spacingTheme tokens
Classes or attributes on one partComponent parts
Labels and languagesCopy and translations
Shape, position, button orderConsentBanner props such as variant, position, layout and primaryButton
Different markup or orderCompound parts, on this page
Entirely custom UI and behaviorHeadless hooks

For a banner that shares nothing with the stock markup, use the headless hooks. For one changed class, token or prop, see customize.

Compose the banner

CookieBanner keeps the stock card, title and copy, and renders each action the policy asks for as your design system's button. BrandButton stands in for that button:

src/components/brand-button.tsx
import { forwardRef } from 'react';
import type { ButtonHTMLAttributes, ForwardedRef } from 'react';

interface BrandButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
	tone?: 'solid' | 'outline';
}

const renderBrandButton = function renderBrandButton(
	{ className, tone = 'outline', type, ...props }: BrandButtonProps,
	ref: ForwardedRef<HTMLButtonElement>
) {
	return (
		<button
			ref={ref}
			{...props}
			className={['brand-button', className].filter(Boolean).join(' ')}
			data-tone={tone}
			// Under `asChild`, c15t passes no type. Default to `button` so an
			// action never submits a surrounding form.
			type={type === 'submit' ? 'submit' : 'button'}
		/>
	);
};

/**
 * Your design system's button. To work under `asChild`, it forwards its ref
 * and passes every other prop to the `button` element, so c15t's click
 * handler, `data-action` and class names reach the DOM.
 */
export const BrandButton = forwardRef(renderBrandButton);

BrandButton.displayName = 'BrandButton';
src/components/cookie-banner.tsx
import { ConsentBanner, useTranslations } from 'c15t/tanstack-start';

import { BrandButton } from './brand-button';

const actionParts = {
	accept: ConsentBanner.AcceptButton,
	customize: ConsentBanner.CustomizeButton,
	dismiss: ConsentBanner.DismissButton,
	reject: ConsentBanner.RejectButton,
} as const;

/**
 * The stock card, title and copy, with each action the policy asks for
 * rendered as your own button. `PolicyActions` decides which actions appear
 * and in what order, so a notice still gets its acknowledgement.
 */
export const CookieBanner = () => {
	const { common } = useTranslations();
	const labels = {
		accept: common.acceptAll,
		customize: common.customize,
		dismiss: common.acknowledge,
		reject: common.rejectAll,
	};

	return (
		<ConsentBanner.Root>
			<ConsentBanner.Card>
				<ConsentBanner.Header>
					<ConsentBanner.Title />
					<ConsentBanner.Description />
				</ConsentBanner.Header>
				<ConsentBanner.PolicyActions
					renderAction={(action, { key, isPrimary, ...props }) => {
						if (action === 'save') {
							return null;
						}
						const Action = actionParts[action];
						return (
							<Action key={key} {...props} asChild noStyle>
								<BrandButton tone={isPrimary ? 'solid' : 'outline'}>
									{labels[action]}
								</BrandButton>
							</Action>
						);
					}}
				/>
			</ConsentBanner.Card>
		</ConsentBanner.Root>
	);
};

c15t/tanstack-start re-exports every c15t/react component, so ConsentBanner and its parts come from the same import as ConsentRoot.

The parts read the provider that ConsentRoot renders. In the src/routes/__root.tsx file from the quickstart, import CookieBanner from ../components/cookie-banner and render it in place of ConsentBanner, next to ConsentDialog:

src/routes/__root.tsx (partial)
<ConsentRoot state={consent} scripts={scripts}>
	<Outlet />
	<CookieBanner />
	<ConsentDialog />
	<footer>
		<ConsentDialogLink>Privacy settings</ConsentDialogLink>
	</footer>
</ConsentRoot>;

The root route renders on the server, and CookieBanner renders there with it, the same as the stock banner.

ConsentBanner.Root renders only while the banner is the active surface, the same condition the stock ConsentBanner uses, so CookieBanner needs no visibility logic. It renders nothing before a policy resolves and closes once the visitor chooses.

ConsentBanner.PolicyActions renders the actions the policy asks for, in the policy's order and groups, with any rights links, such as "Do not sell or share my data", before them. For each action it calls renderAction with the action name and the props c15t would give the stock button: key, consentAction, isPrimary, and a style that stretches the buttons when the layout fills the row. Return an element to replace that button, or null to keep the stock one. The banner never renders save, so the example skips it.

Each action part gets those props plus asChild and noStyle. asChild renders BrandButton in place of c15t's button, and noStyle drops c15t's button classes so they do not compete with yours. The part keeps its behavior. Accept saves every category and Reject saves only necessary, and both close the banner. Customize opens the preference dialog. Dismiss records that the visitor saw a notice, without recording a choice.

With asChild, the part's default label does not render, because your element keeps its own children. useTranslations() returns the labels the stock buttons use, in the visitor's language.

Under a notice, the policy asks only for an acknowledgement, so PolicyActions passes dismiss and CookieBanner renders one button. If you place ConsentBanner.AcceptButton and ConsentBanner.RejectButton yourself instead, they render under every policy, including one that owes only a notice.

Every part is a property of ConsentBanner. All parts except Root and PolicyActions forward their ref, and they accept the attributes of the element they render plus noStyle.

PartRendersProps
RootThe div that positions the banner, only while the banner shows. Renders the backdrop itself when blocking.variant, position, blocking, models (default ['opt-in', 'opt-out']), uiSource (default 'banner'), noStyle, disableAnimation
CardThe visible div, labelled with the title. role="region", or role="dialog" with aria-modal="true" and a focus trap when blocking.asChild
HeaderA div for the title and copy.asChild
TitleAn h2 with the translated title, or the notice title under a notice.children replace the text
DescriptionA div with the translated copy and the configured legal links.legalLinks, asChild
PolicyActionsFooter, the rights links and the policy's action groups.renderAction, children replace the rights links
FooterA div for the actions.asChild
FooterSubGroupA div for one group of actions. Actions is an alias.asChild
AcceptButtonA button that saves every category and closes the banner.action button props
RejectButtonA button that saves only necessary and closes the banner.action button props
CustomizeButtonA button that opens the preference dialog.action button props
DismissButtonA button that acknowledges a notice.action button props
RightsA div with one RightLink per right the policy recommends, or nothing.rights, asChild
RightLinkA button styled as underlined text that opens the preference dialog.right ('opt-out' or 'preferences'), asChild
OverlayThe backdrop of a blocking banner. Root already renders it, so do not add it.none

Content is an alias of Card.

The action buttons take asChild, noStyle, consentAction, isPrimary, variant ('primary' or 'neutral'), mode ('filled', 'stroke', 'lighter' or 'ghost'), onClick and any button attribute. Your onClick runs before the action, and calling event.preventDefault() in it skips the action. consentAction sets data-action and selects the theme.consentActions entry. PolicyActions passes it for you, and DismissButton sets it itself. A button you place yourself has no data-action and no per-action theme until you pass consentAction.

useConsentBannerSurface() returns the variant, position, positionSource and blocking that Root resolved, for your own elements that depend on the banner's shape.

ConsentBanner lists the stock banner's props, variants and data attributes.

Render a part as your own element

asChild swaps the part's element for the one element you pass as its child. Card, Header, Description, Footer, FooterSubGroup, Rights, RightLink and the action buttons support it. Root and Overlay ignore it, and Title always renders an h2, so a child you pass to Title ends up inside that h2. Description with asChild drops the inline legal links.

The part merges its props onto your element:

  • The part's props apply first, and props you set on the child win.
  • className keeps both, the part's classes first.
  • style merges, and your values win per property.
  • Event handlers both run, yours first. On an action button, calling event.preventDefault() in yours skips c15t's action.
  • The part's ref and your child's ref both receive the DOM node.
  • Your child keeps its own children. The part's default content, such as a button label, does not render.

Your component has to pass its ref and every prop it does not use to its DOM element. Otherwise c15t's click handler, data-action and focus handling never reach the page. The BrandButton in the example does both. Pass exactly one element. Text, a fragment or several children render nothing.

Action buttons pass no type under asChild, so give your button a default of type="button". Without it, the button submits a surrounding form.

Compose the preference dialog

ConsentDialog has parts too. Compose it when the dialog needs a different header or footer, and keep ConsentWidget inside Content. The widget owns the category switches, the draft and the policy's Save, Accept and Reject buttons.

PartRendersProps
RootThe dialog in a portal on document.body, with its open state, backdrop, focus trap, scroll lock and Escape to close.open, models (default ['opt-in', 'opt-out', 'none']), overlay (a node, or false for none), noStyle, disableAnimation, scrollLock, trapFocus, uiSource (default 'dialog')
CardThe panel div.className, style, noStyle
HeaderA div for the title and copy.asChild
HeaderTitleAn h2 with id="consent-dialog-title".children replace the text
HeaderDescriptionA div with id="consent-dialog-description" and the legal links.legalLinks, asChild
ContentA div for ConsentWidget.asChild
FooterA div with the branding tag, unless you pass children or hideBranding.hideBranding, asChild
ConsentCustomizationCardThe stock card with all of the above.legalLinks, hideBranding, noStyle
OverlayThe backdrop. Root already renders it.none
<ConsentDialog.Root>
  <ConsentDialog.Card>
    <ConsentDialog.Header>
      <ConsentDialog.HeaderTitle>Your privacy choices</ConsentDialog.HeaderTitle>
      <ConsentDialog.HeaderDescription />
    </ConsentDialog.Header>
    <ConsentDialog.Content>
      <ConsentWidget />
    </ConsentDialog.Content>
    <ConsentDialog.Footer hideBranding />
  </ConsentDialog.Card>
</ConsentDialog.Root>

Render the composed dialog in place of ConsentDialog, next to your banner. The stock ConsentDialog downloads its code when the dialog first opens. The parts load that same code when they first render, and a composed ConsentDialog.Root renders on every page, so the code downloads with the page. c15t/react/components/consent-dialog exports the same parts without the deferral.

ConsentWidget has parts as well, such as ConsentWidget.Root, ConsentWidget.Accordion, ConsentWidget.AccordionItems and ConsentWidget.PolicyActions, for a preference list with your own layout.

c15t/tanstack-start exports ConsentDialog and ConsentWidget with their parts. It has no components/consent-dialog subpath, so import the parts without the deferral from c15t/react/components/consent-dialog. ConsentDialog and ConsentWidget list every part and its slots.

Keep the banner accessible

The stock parts carry the roles, labels and focus handling. A composed banner keeps them as long as you keep the parts that own them:

  • Card owns the banner's role, label and focus trap. With asChild, your element receives them, so do not set role or aria-modal on it.
  • Card takes its aria-label from the translated title, not from text you pass to Title. If you change the title text, pass the same text as aria-label on Card.
  • To attach your own ref to Card, pass a ref object from useRef. A callback ref turns off the focus trap on a blocking banner.
  • Render each action as a real button. Its text is the accessible name, so keep the label inside your button.
  • Keep a way to reopen preferences after the banner closes, such as the ConsentDialogLink in your footer.
  • The dialog's Root names the panel with the ids on HeaderTitle and HeaderDescription. Keep both parts, or put id="consent-dialog-title" and id="consent-dialog-description" on your own heading and copy.

Keep theme tokens and slots working

The parts render the stock class names, and the banner's Root renders c15t's rules, so c15t's styles and --c15t-* tokens style a composed banner the way they style the stock one.

Slots in the provider's options.components reach each part by key: banner.root, banner.card, banner.header, banner.title, description.banner, banner.footer and banner.actions, banner.actionGroup, banner.rights and banner.rightLink. A class you pass to a part joins its stock and slot classes. The stock ConsentBanner also renders the banner.cardShell wrapper and the "Secured by" tag, and a composed banner renders neither.

Action buttons read the button.primary or button.secondary slot and the theme.consentActions entry for their consentAction. noStyle on a button drops c15t's button classes and keeps slot classes. noStyle on Root removes the built-in styling from every part inside it.

Customize shows where to set tokens, slots and consentActions through ConsentRoot's options.

Check the result

  1. Clear site data and reload. The composed banner appears with your buttons. In DevTools Elements, each action is your element with a data-action attribute. In DevTools Network, no optional vendor request has fired.
  2. Tab through the banner. Every action is reachable in the policy's order and shows a focus ring. Press Enter on Customize, and the preference dialog opens.
  3. Click Reject all. The banner closes. Reload, and it stays closed with the vendors still blocked.
  4. Reopen preferences from your footer link. The optional categories are off. Allow one, save, and only that category's vendors load.
  5. If your policies include a notice region, test it too. The banner shows your acknowledgement button and no Accept or Reject.

Verify consent has the full checklist.