Skip to main content

Customization

Banner designs

Pick a design

Each design below is a real c15t banner. The screenshots and the code come from the same Storybook stories, which CI runs to check that each design renders and that Accept and Reject record a choice.

DesignCustomization levelFrameworks
Bottom barPropsReact, Vue, Astro, Svelte
Branded cardTheme tokensReact
Choice wallPropsReact
Slim barCompound parts or slotsReact, Vue, Svelte
Headless barHeadlessReact, Vue, Svelte, HTML

Next.js and TanStack Start apps use the React code, with the same components imported from c15t/next or c15t/tanstack-start. Nuxt uses the Vue code and SvelteKit uses the Svelte code. Customization explains when to use each level.

Every design keeps the policy in charge. The policy decides which buttons a visitor gets, and none of these designs records consent until the visitor clicks a button.

Bottom bar

A white bar across the bottom of a coffee shop page, with the heading "We value your privacy", one line of copy, and Reject All, Accept All and Customize buttons on the right

The stock banner becomes a full-width bar along the bottom edge with one prop, variant="bar". The title and copy sit on the left and the buttons on the right.

Render CookieBanner in place of ConsentBanner, inside your ConsentProvider and next to ConsentDialog.

import { ConsentBanner } from 'c15t/react';

/** The stock banner, drawn as a full-width bar along the bottom edge. */
export const CookieBanner = () => (
	<ConsentBanner variant="bar" position="bottom" />
);

The HTML script tag takes the same shape through presentation.prompt. See customize the script tag.

Branded card

A floating card in the bottom-left corner with a warm off-white surface, serif type, small corners, and green Reject All and Accept All buttons next to an outlined Customize button

Theme tokens change the colors, type and radius of the stock card without touching its markup. This theme sets a green primary color, an off-white surface, a serif font and four-pixel corners, and fills the actions the policy marks as primary. Here the policy marks both Reject All and Accept All as primary, so neither choice stands out.

src/consent-theme.ts
import { defineTheme } from 'c15t/react';

export const brandTheme = defineTheme({
	colors: {
		border: '#e4dccf',
		primary: '#2f6f4e',
		primaryHover: '#24563c',
		surface: '#fbf8f3',
		textOnPrimary: '#ffffff',
	},
	consentActions: { primary: { mode: 'filled', variant: 'primary' } },
	radius: { lg: '4px', md: '4px' },
	typography: { fontFamily: 'Georgia, "Times New Roman", serif' },
});
src/consent-ui.tsx
import { ConsentBanner, ConsentDialog, ConsentTheme } from 'c15t/react';

import { brandTheme } from './consent-theme';

/**
 * Render inside your ConsentProvider, and pass `theme: brandTheme` in the
 * provider options so the action styles apply too.
 */
export const ConsentUI = () => (
	<>
		<ConsentTheme theme={brandTheme} />
		<ConsentBanner />
		<ConsentDialog />
	</>
);

ConsentTheme renders the tokens as a <style> element. In a server-rendered app, render it from a Server Component or the root layout so the tokens arrive with the HTML. The provider's theme option reads only consentActions and slot styles, so pass the same object there for the filled buttons:

<ConsentProvider options={{ mode, theme: brandTheme }}>

The preferences dialog reads the same tokens:

The Privacy Settings dialog over the dimmed coffee shop page, with the same off-white surface, serif type and green Reject All and Accept All buttons

Vue, Svelte, Astro and the script tag set the same tokens their own way. See customize for Vue, Svelte, Astro or HTML, and theme tokens for every token.

Choice wall

A centered consent card over a dimmed coffee shop page, with Reject All, Accept All and Customize buttons

variant="wall" centers the stock banner over a backdrop, locks scrolling and traps focus until the visitor chooses. Use it only where a policy requires a choice before the page is usable.

src/cookie-banner.tsx
import { ConsentBanner } from 'c15t/react';

/** A centered prompt with a backdrop. The page waits for a choice. */
export const CookieBanner = () => <ConsentBanner variant="wall" />;

A choice wall always blocks. A notice never blocks, so a wall requested for a notice falls back to the floating card.

Slim bar

A thin white bar along the bottom of the page with the heading and one line of copy on the left, and Reject All, Accept All and Customize buttons on the same row

The slim bar puts the title, the copy and the buttons on one row, so it is shorter than the stock bar. React builds it from the ConsentBanner compound parts. Vue and Svelte have no compound banner parts, so they add classes to the stock bar's card and header through slots. All three use the same stylesheet, and all three keep the buttons the policy requires.

ConsentBanner.PolicyActions renders the policy's buttons, so a required Reject button stays. Render CookieBanner inside your ConsentProvider, next to ConsentDialog.

import { ConsentBanner } from 'c15t/react';

import './slim-bar.css';

/**
 * The title, copy and the policy's buttons in a single row. The compound
 * parts keep the policy's actions, so a required Reject button stays.
 */
export const CookieBanner = () => (
	<ConsentBanner.Root variant="bar">
		<ConsentBanner.Card className="slim-bar">
			<ConsentBanner.Header className="slim-bar__text">
				<ConsentBanner.Title />
				<ConsentBanner.Description />
			</ConsentBanner.Header>
			<ConsentBanner.PolicyActions />
		</ConsentBanner.Card>
	</ConsentBanner.Root>
);
src/slim-bar.css
.slim-bar {
	box-sizing: border-box;
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 0.5rem 1.5rem;
	padding: 0.5rem 1.5rem;
}

/* Title and description share one line. */
.slim-bar__text {
	display: flex;
	flex: 1 1 20rem;
	flex-direction: row;
	flex-wrap: wrap;
	align-items: baseline;
	gap: 0 0.5rem;
	padding: 0;
}

/* The actions share the text's row, so drop the footer's own box. */
.slim-bar > [data-testid='consent-banner-footer'] {
	padding: 0;
	border: 0;
	background: none;
}

Headless bar

A dark navy bar across the bottom of the page with one line of white copy, an underlined Preferences link, and white pill-shaped Reject all and Accept all buttons

The headless bar is your own markup and CSS, driven by c15t's headless API. It shows the same design in every framework. The policy still decides which buttons appear and in what order, the Preferences button is always there, and a choice is recorded only when the visitor clicks.

useHeadlessConsentUI() from c15t/react/headless returns the banner state and performAction. Render CookieBar in place of ConsentBanner, and keep ConsentDialog, which Preferences opens.

import { useHeadlessConsentUI } from 'c15t/react/headless';
import type { HeadlessConsentBannerAction } from 'c15t/react/headless';

import './cookie-bar.css';

const labels: Record<HeadlessConsentBannerAction, string> = {
	accept: 'Accept all',
	customize: 'Preferences',
	dismiss: 'Got it',
	reject: 'Reject all',
	save: 'Save',
};

export const CookieBar = () => {
	const { banner, openDialog, performAction } = useHeadlessConsentUI();

	if (!banner.isVisible) {
		return null;
	}

	// The policy decides which actions this visitor gets, and in what order.
	// Preferences has its own button, so it is always there.
	const actions = banner.orderedActions.filter(
		(action) => action !== 'customize'
	);

	return (
		<section className="cookie-bar" aria-label="Cookie consent">
			<p className="cookie-bar__text">
				We use cookies to measure traffic and improve this site. Choose which
				ones can run.
			</p>
			<div className="cookie-bar__actions">
				<button type="button" className="cookie-bar__link" onClick={openDialog}>
					Preferences
				</button>
				{actions.map((action) => (
					<button
						key={action}
						type="button"
						className="cookie-bar__button"
						onClick={() => performAction(action)}
					>
						{labels[action]}
					</button>
				))}
			</div>
		</section>
	);
};

React, Vue and Svelte import the same stylesheet, cookie-bar.css. The HTML block contains it inline.

src/cookie-bar.css
.cookie-bar {
	position: fixed;
	inset: auto 0 0;
	z-index: 50;
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 0.75rem 1.5rem;
	padding: 0.875rem 1.5rem;
	background: #14213d;
	color: #f8f9fb;
	font:
		0.9375rem/1.5 system-ui,
		sans-serif;
	box-shadow: 0 -4px 16px rgb(0 0 0 / 0.15);
}

.cookie-bar__text {
	flex: 1 1 20rem;
	margin: 0;
}

.cookie-bar__actions {
	display: flex;
	flex-wrap: wrap;
	gap: 0.5rem;
}

.cookie-bar button {
	padding: 0.5rem 1rem;
	border: 1px solid #f8f9fb;
	border-radius: 999px;
	background: transparent;
	color: inherit;
	font: inherit;
	cursor: pointer;
}

.cookie-bar .cookie-bar__button {
	background: #f8f9fb;
	color: #14213d;
	font-weight: 600;
}

.cookie-bar .cookie-bar__link {
	border-color: transparent;
	text-decoration: underline;
	text-underline-offset: 3px;
}

.cookie-bar button:focus-visible {
	outline: 2px solid #fca311;
	outline-offset: 2px;
}
/* On narrow screens, Reject and Accept share a row at equal width. */
@media (max-width: 30rem) {
	.cookie-bar__actions {
		width: 100%;
	}

	.cookie-bar__actions button {
		flex: 1 1 40%;
	}

	.cookie-bar__actions .cookie-bar__link {
		flex-basis: 100%;
	}
}

On a 375-pixel screen the copy takes the full width, Preferences sits on its own line, and Reject all and Accept all share a row at equal width:

The dark headless bar on a narrow phone screen, with the copy on two lines, a centered Preferences link, and Reject all and Accept all buttons side by side

In the HTML version, Preferences opens the switches inside the bar:

The dark bar grown taller, with checkboxes for Functionality, Measurement, Experience and Marketing above Preferences, Reject all, Accept all and Save buttons

A headless UI owns its focus handling and labels. Test it with a keyboard and a screen reader. The headless pages for React, Vue and Svelte cover notices and custom preference forms.

Check a design

Run these checks after you adopt a design:

  1. Clear site data and reload. The banner appears and no optional vendor request appears in DevTools Network.
  2. Click Reject all, reload, and confirm the banner stays closed and the vendors stay blocked.
  3. Open preferences from your footer link, allow one category, save, and confirm only that category's vendors load.
  4. Test a 375-pixel viewport with your longest translation, and tab through every button.

Verify consent has the full list.

Run the designs

The source repository renders each design in its Storybook under Docs / Banner designs: apps/storybook-react, apps/storybook-vue, apps/storybook-svelte and apps/storybook-astro. Each story uses an in-memory policy and loads no analytics. The HTML headless bar runs at /consent-example/headless in internals/fixtures/script-tag, which the example acceptance tests cover.