Skip to main content

Customization

Dark mode

How c15t turns dark

c15t's stylesheet switches every --c15t-* color token to its dark value when <html> has a dark or a c15t-dark class. The colorScheme option decides whether c15t sets c15t-dark itself:

colorSchemeWhat c15t does with c15t-dark
'light'Removes it once
'dark'Adds it once
'system'Follows prefers-color-scheme, including changes while the page is open
UnsetCopies your dark class into c15t-dark and follows it as it changes
nullLeaves it alone. Your site sets it

Astro also accepts 'none', which means the same as null.

'light' only removes c15t-dark. If your site puts dark on <html>, the stylesheet still switches the tokens to dark. Pick one class convention for your site and let c15t follow it.

Defaults in each framework

FrameworkWhere to set itDefault
Next.js, TanStack Startoptions.colorScheme on ConsentRoot, and colorScheme on ConsentThemeUnset: copies dark
ReactcolorScheme in the ConsentProvider options, and on ConsentThemeUnset: copies dark
NuxtcolorScheme under the c15t key in nuxt.config.ts, or in app/app.config.tsUnset: copies dark
VuecolorScheme in the c15tVue optionsUnset: copies dark
AstrocolorScheme in the c15t() integration options'system'
Svelte, SvelteKitcolorScheme on ConsentProvider (Svelte) or ConsentRoot (SvelteKit)Unset: copies dark
HTMLdata-color-scheme on the script tag: light, dark, system or nonesystem
JavaScriptui.colorScheme in init()'system'

React, Vue and Svelte apps usually already have a theme switch that toggles a dark class, such as next-themes. Copying that class keeps c15t in step with the site without extra code.

Astro and the script tag default to 'system' because neither can rely on a site convention. Astro paints the banner from server HTML before any site script runs, and Astro sites share no dark class convention, so following the operating system is the one choice its inline script can make correctly. A plain HTML page has no convention either. A site that has one opts in with null.

Set colorScheme null in Nuxt

In Nuxt, set colorScheme: null under the c15t key in nuxt.config.ts or in app/app.config.ts. Nuxt drops a null in inline module options, such as modules: [['@c15t/vue', { colorScheme: null }]], before the module reads it, so c15t would copy your dark class as if colorScheme were unset.

Follow your site's theme switch

In React, Next.js, TanStack Start, Vue, Nuxt, Svelte and SvelteKit, leave colorScheme unset and toggle dark on <html>. c15t watches the class and switches with it. Include the class in the server HTML when your site renders dark, so the first paint matches.

In Astro, set colorScheme: 'none' and toggle c15t-dark together with your own class. A ClientRouter navigation replaces the attributes of <html>, so set the class again on astro:after-swap if your theme script does not.

With the script tag, add data-color-scheme="none". With init(), pass ui: { colorScheme: null }. The UI is then dark while <html> has dark or c15t-dark, and follows the class as it changes. The UI renders in a shadow root that the page's class cannot reach, so c15t copies the class onto the shadow host.

If your site sets c15t-dark itself in React, Next.js, TanStack Start, Vue, Nuxt, Svelte or SvelteKit, pass colorScheme: null so c15t leaves it alone.

Set your own dark colors

Theme tokens take a dark object with the same color keys as colors. Every color you set in colors also applies in dark mode, unless dark sets it too, so give a dark value for each brand color:

consent-theme.ts
export const theme = {
	colors: { primary: '#2f6f4e', primaryHover: '#24563c', surface: '#fbf8f3' },
	dark: { primary: '#7fd1a8', primaryHover: '#9fdcbd', surface: '#1b1f1d' },
};

Where the theme goes depends on the framework:

  • React, Next.js, TanStack Start. Pass it to ConsentTheme. To write the CSS outside a component, call generateThemeCSS(theme, colorScheme) from c15t/react/utils, or @c15t/react/utils if you installed the scoped package. It writes the same CSS as ConsentTheme.
  • Vue, Nuxt. Pass it as theme in the plugin or module options. It goes into the <style id="c15t-css-vars"> element with tokens.
  • Astro. Pass it as theme in the integration options.
  • SvelteKit, Svelte. Pass it to generateThemeCSS(theme, colorScheme) from @c15t/ui/theme on the server.
  • HTML, JavaScript. Pass it as ui.theme.

This Nuxt example follows the system setting and uses its own dark primary color. Every other dark token keeps c15t's default:

nuxt.config.ts (c15t options)
// Follow the visitor's system setting, with a dark primary of our own.
colorScheme: 'system',
theme: { dark: { primary: '#7fd1a8' } },

To set dark values in plain CSS instead, write them for both classes:

:root.dark,
:root.c15t-dark {
  --c15t-primary: #7fd1a8;
  --c15t-surface: #1b1f1d;
}

A generated theme, such as the output of ConsentTheme or generateThemeCSS, writes its dark values on :root:root.dark and :root:root.c15t-dark. Those selectors outrank the rule above. If you use both, put dark values in the theme, or repeat :root in your own selectors.

Paint dark on the first frame

A banner that renders light and then turns dark flashes. How to avoid that depends on where the banner first renders:

FrameworkWhat to do
Next.js, TanStack StartRender <ConsentTheme theme={theme} colorScheme="system" /> on the server, with the same value as options.colorScheme. 'dark' writes dark tokens as the default and 'system' adds a prefers-color-scheme media query, so the server HTML is dark before hydration. With colorScheme unset, put your dark class in the server HTML.
ReactRender <ConsentTheme colorScheme="system" /> next to the provider, with the same value as the provider's colorScheme. Its CSS makes the tokens dark from the first frame, before the provider sets the class. In a server-rendered React app, render it on the server.
NuxtFor 'dark' and 'system', the module adds an inline script to <head> that sets c15t-dark before the server-rendered banner paints. It carries the module's nonce. With colorScheme unset, put your dark class in the server HTML.
VueThe plugin applies colorScheme and writes the tokens when you install it, before the first render.
AstroConsentScript in <head> sets the class from an inline script before the banner paints, and c15t sets it again after each ClientRouter navigation. Keep ConsentScript in your layout's <head>.
SvelteThe provider sets the class as it mounts. With colorScheme unset, the stylesheet reads your dark class directly, so the tokens are dark from the first frame.
SvelteKitPass the scheme to generateThemeCSS(theme, 'system') in your server load, so the CSS in <svelte:head> already has the media query. With colorScheme unset, put your dark class in the server HTML.
HTML, JavaScriptc15t applies the scheme when it mounts the UI, before any surface renders.

Theme tokens lists every color token, and your framework's customize page shows where its theme goes.

Check the result

  1. Open the page in a private window with your operating system in dark mode, or add your dark class to <html>.
  2. The banner is dark on its first frame. Reload with the Network panel throttled to see the first paint.
  3. Open the preference dialog. It uses the same dark tokens as the banner.
  4. Switch the scheme while the page is open. With 'system' or an unset colorScheme, the banner and dialog follow without a reload.
  5. Check the contrast of the primary button text in both schemes.