Display Axes

Five settings that change how the product looks without changing what it shows: light or dark, normal or high contrast, how tight the vertical rhythm is, how large the text is, and which brand colour. They are independent of one another and of the two navigation axes, they are per-user and per-device, and all five are wired once at the root layout.

Five axes, five attributes

Each axis is a provider, a control, a hook, one attribute on <html> and one localStorage key. Nothing crosses between them: switching to dark does not change the contrast, and going compact does not change the font size. Do not confuse these with the two navigation axes — WorkspaceSwitcher (which data) and AppLauncher (which product) — which are a different set entirely.

All five persist the same way: one string in localStorage under the key named on each card. That makes them per user and per device — nothing is sent to a server, and the same person gets large text on their phone and compact rows on their laptop without the two fighting. If you need a choice to follow an account across machines, that is an app-level preference you store yourself and feed in through defaultValue.

1Light / dark

ThemeProvider · ThemeToggle · useTheme (from next-themes)

class="dark" ·comitor-theme

The only axis backed by next-themes, and the only one that reads an OS preference.

2Contrast palette

ContrastProvider · ContrastToggle · useContrast

data-contrast="high" ·comitor-contrast

Swaps every CSS variable for the measured, high-contrast set. Orthogonal to light/dark: both palettes have a light and a dark build.

3Layout density

DensityProvider · DensityToggle · useDensity

data-density="compact" ·comitor-density

Rewrites five vertical-rhythm tokens — table rows, menu items, list items, sidebar items, table head height. Vertical only.

4Font size

FontSizeProvider · FontSizeControl · useFontSize

data-font-size="sm" | "lg" ·comitor-font-size

Scales the root font-size by percentage, so icons sized off the spacing scale keep their ratio to the text.

5Theme palette

PaletteProvider · PalettePicker · usePalette

data-palette="teal" | … ·comitor-palette

New in 3.15.0. Swaps the brand colour — gold by default, or one of eight others — on everything that follows it through a role token. Orthogonal to the other four, contrast included.

The controls, live

These are real

This documentation site runs all five providers in its own root layout, so the controls below are not a demo copy — they drive the page you are reading, and every other page on the site. Your choices are written to your browser's localStorage and will still be there next time. Every control here is bidirectional, so you can undo a choice with the same control — and the Reset display settings button further down is live: one click puts the last four axes back to their defaults.

Appearance

Light, dark, or follow the system.

Darkens borders, switches and secondary text. Helpful on dim screens or outdoors.

Reduces vertical spacing between rows in tables, lists and menus.

Font size

Theme color

Open invoices

InvoiceClientStatus
INV-2041Acme CorpPaid
INV-2042Beta LtdOverdue
INV-2043Cedar WorksDraft

Watch the card while you use the two right-hand controls. Compact layout moves the table's row padding and header height — --row-py and --table-head-h — and nothing horizontal, because “compact” means more rows in view, not text pushed against the edge. Font size moves the root size, so the badge, the button and the icons scale with the text instead of drifting out of proportion with it.

Theme palette

New in 3.15.0. Nine palettes, named by colour — gold, the default, then green, teal, blue, indigo, violet, rose, orange and graphite — each one a <html data-palette="…"> block in the package’s stylesheet; gold writes no attribute at all. A palette replaces the brand colour of everything that follows it: the primary button and its hover, a switch, checkbox or radio that is on, the focus ring, the active marker, links, the accent and sidebar-accent surfaces in light mode, an app’s default accent and chart-1 — in light and dark, at normal and at high contrast. This site mounts PaletteProvider at its root, so the picker below recolours the whole site, the same as the palette button in the header.

Theme color

Each swatch paints itself in its own palette, whatever the page is using.

Current palette: reading…

Default badge

A link in running text: Foundations · Colour.

Pick Green and look at the primary button: its text turns to ink, not white. Green’s fill is mid-light, and white on it measures only 3.54:1. That is why text on a brand fill is always text-primary-foreground, never text-white — the palette decides which one it is.

What follows the palette, and what does not

The brand-tinted role tokens — --primary, --primary-hover, --ring, --accent, --link-ink, --app-accent-ink, --chart-1, --control-on — point at a group of --brand-* variables on :root, and each palette redeclares only that group. So the rule is the colour contract you already follow:

  • Anything that goes through a role token follows — bg-primary, text-primary-ink, text-link-ink, ring, bg-control-on, text-app-accent-ink, and every package component built on them.
  • Anything written with the raw gold scale stays gold — bg-gold-300, or a token overridden as var(--gold-500). Nothing warns you, because gold is still a valid colour. Status colours, the secondary colours and the gold-* scale itself do not move either; gold-* is now only for what must always be gold, such as the Comitor logo.
  • The primary hover is a token of its own, --primary-hover (utility bg-primary-hover): step 400 of the palette in light, a dedicated step in dark. Button’s default and primary variants use it where they used to write hover:bg-gold-400; under gold it is the same #B88A00 as before.
  • The brand-50…600 scale is not a role token. It is the palette-following copy of the gold scale, and the package uses it for its tonal fills — Badge default, the current Pagination page, StatusPill’s primary tone, the Gantt bar. For an app it is still a raw scale: it does not follow .dark or high contrast. Use those components rather than painting bg-brand-100 yourself. The role variables --brand, --brand-link and the rest have no utility at all — write bg-primary.

Two set-ups — choose one

Who picks the palette is a product decision. A product in Comitor gold does nothing. A product with a fixed colour of its own writes the attribute on <html> in the root layout and mounts no provider. A product that lets the user choose mounts PaletteProvider — with its own colour as defaultValue if it has one — and puts a PalettePicker on its display-settings page.

app/layout.tsx
// app/layout.tsx — pick ONE of the two. Never both.

// A. The product has its own colour and the user cannot change it.
//    The attribute, written on the server. No PaletteProvider, no picker, no script.
<html lang="en" suppressHydrationWarning data-palette="teal">

// B. The user chooses. The provider, with the product's colour as defaultValue; its
//    anti-flash script applies that default on a first visit's first paint (3.15.0).
<html lang="en" suppressHydrationWarning>
  <body>
    <ThemeProvider nonce={nonce}>
      {/* …the other display providers… */}
      <PaletteProvider defaultValue="teal" nonce={nonce}>
        {children}
      </PaletteProvider>
    </ThemeProvider>
  </body>
</html>

// ✗ data-palette="teal" on <html> PLUS <PaletteProvider> without defaultValue:
//   the provider's script lands on gold (its default), and at the package default it
//   REMOVES the attribute — the page is gold from the very first paint.

Never the attribute plus a provider without defaultValue

The provider’s anti-flash script lands on the provider’s default when nothing is stored, and at the package default — gold — it removes the attribute, so that a user who chose gold is not overruled by a server-written data-palette. Combine the two without defaultValue and your fixed colour is gone from the first paint. This site is set-up B with the package default: a provider, no attribute, no defaultValue.

Only on <html>

The role tokens are resolved on :root and inherited as resolved values. A data-palette on an inner element redeclares the --brand-* group on that element only — the buttons and links inside it keep the page’s colour. That is exactly how each swatch of PalettePicker manages to paint another palette’s colour, and why it is not a way to theme one region. For an area that must stand out, use a tone or the app’s own accent.

Outside CSS: themePalettes

An email, a PDF or a canvas chart cannot read a CSS variable. themePalettes (with its key type ThemePaletteName), exported from @comitor/ui/tokens and the main entry, carries the hex of every role for every palette — light, dark and the scale.

email-colors.ts
// Outside CSS — an email template, a PDF, a canvas chart — there are no CSS variables
// to follow. themePalettes holds the hex of each role, per palette, per mode.
import { themePalettes, type ThemePaletteName } from '@comitor/ui/tokens'

export function emailColors(palette: ThemePaletteName) {
  const { light, scale } = themePalettes[palette]
  return {
    buttonBg: light.brand, // the same fill as bg-primary
    buttonText: light.brandForeground, // ink on green, not white — read it, do not assume
    link: light.link,
    tint: scale[50],
  }
}

The eight new palettes meet WCAG AA at normal contrast — 4.5:1 for text, 3:1 for an on-control, the focus ring and the active marker. The below-threshold pairs listed on Foundations · Colour belong to gold alone, the approved design-system look.

Reading an axis in your own code

Four hooks ship with the package; useTheme is not one of them, because axis 1 is next-themes and you read it with next-themes' own hook. All four package hooks throw outside their provider rather than quietly returning a default — a control that silently does nothing is far harder to diagnose than one that crashes on the first render. The table below is live: it reflects the choices you just made. Its third column is not derived from the hook values — it is read off document.documentElement through a MutationObserver, so the remove-at-default rule below is observable rather than asserted. The snippet leaves that part out; it shows the hooks only.

AxisHook valueOn <html> right now
Light / darkreading…reading…
Contrast palettereading…reading…
Layout densityreading…reading…
Font sizereading…reading…
Theme palettereading…reading…

Each hook also returns mounted, and it is not optional decoration. The provider must hold its default value through the first client render or React reports a hydration mismatch, so the stored choice only lands in state after the first effect. In that gap the hook reports the default, not the truth. The four axis controls gate on it the same way — disabled={!mounted} — so a fast clicker cannot overwrite their own saved setting with the placeholder one. ThemeToggle has no provider flag to read, so it keeps a mounted state of its own and answers the same problem differently: until its first effect it renders a same-size empty <span aria-hidden /> rather than a disabled button — nothing to click, and the layout still does not jump.

Persisting a choice as an account default

All five controls take a notify callback: onContrastChange has been there since ContrastToggle shipped, and 1.8.0 added onThemeChange, onDensityChange and onFontSizeChange so the other three match; PalettePicker arrived in 3.15.0 with onPaletteChange. They exist for the one thing the localStorage model cannot do on its own — letting the app learn a choice was made, so it can record an account default that follows the user to their next device.

This is a notify callback, not a controlled onChange

Do not wire it up as one. The value in effect stays in localStorage, owned by the provider; the callback only reports that it moved. That is architectural: a display axis has to take effect before the first paint, which only the anti-flash script reading localStorage can do — a value arriving from the server as a prop always lands after that paint, and flashes. So the loop is: the package reports the change → the app stores the default server-side → the next device seeds localStorage from it before the first paint, through DisplayPrefsSeedScript — below.

onThemeChange hands you a ThemeChoice — "light" | "dark" | "system", exported in 1.8.0 — a closed set of three in which "system" is a real value, not “unchosen”. An account default has to store all three: collapse it to a light/dark boolean and you throw away follow the OS, which is exactly what someone who runs dark-by-schedule loses on their second machine. And variant="icon" never emits "system" — it only flips light ⇄ dark — so a UI that must let the user get back to system has to use variant="menu".

Seeding the account default before first paint

New in 3.7.0, in @comitor/ui/shell. DisplayPrefsSeedScript writes the account's defaults into localStorage with a synchronous inline script, ahead of the anti-flash scripts, so the very first frame is already right — and since 3.15.0 it takes a palette key beside theme, contrast, density and fontSize. The provider props cannot do this: defaultValue is the product’s default, used only while a device has nothing stored, so a device that already holds a value never learns that the account changed — and defaultContrast reaches the page only through an effect, after the paint.

The rule is follow the account, unless this device was changed by hand. Beside each key the script keeps a marker, <key>-seeded, holding what it seeded last: no value yet → seed; the account changed and the device still holds the old seed → seed again; the device holds something else → keep it, because the person chose it here. Render it as the first child of <body>, above the providers; with nothing valid to seed it renders nothing — even an empty script is one more thing CSP has to allow.

app/layout.tsx
// app/layout.tsx — a Server Component. The seed is the account's defaults, read
// wherever your app keeps them (a cookie written at sign-in, the session…).
import {
  ContrastProvider,
  DensityProvider,
  DisplayPrefsSeedScript,
  FontSizeProvider,
  PaletteProvider,
  ThemeProvider,
} from '@comitor/ui/shell'
import { headers } from 'next/headers'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get('x-nonce') ?? undefined
  const seed = await readAccountDisplayDefaults() // e.g. { theme: 'dark', contrast: 'high', palette: 'teal' }

  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        {/* FIRST child of <body>, above the five providers: their anti-flash
            scripts run in document order, and must find the seeded values. */}
        <DisplayPrefsSeedScript seed={seed} nonce={nonce} />
        <ThemeProvider nonce={nonce}>
          <ContrastProvider nonce={nonce}>
            <DensityProvider nonce={nonce}>
              <FontSizeProvider nonce={nonce}>
                {/* Only in a product that lets the user choose a palette: without this
                    provider the seed's palette key is stored and never read. */}
                <PaletteProvider nonce={nonce}>{children}</PaletteProvider>
              </FontSizeProvider>
            </DensityProvider>
          </ContrastProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}

The package filters, so the script is not an injection point

A script carrying a nonce is one the browser trusts completely, and a seed usually comes from a cookie the client can edit. So nothing you pass reaches the script text: each value is matched against the package's closed sets (DISPLAY_PREF_VALUES) and what is written is the element of the constant array, under keys that are constants too (DISPLAY_PREF_STORAGE_KEYS — there is no storage-key prop). Measured in the package with ";alert(1)// as a value. The same filter is exported as parseDisplayPrefsSeed, for when you want the clean values yourself.

Wiring all five

Every provider belongs at the root layout, above AppShell rather than inside it — sign-in pages, landing pages and error pages need the same appearance as the app. The layout itself stays a Server Component. What follows is the display-axis part of this site's own layout, which also carries a language segment and ComitorLocaleProvider (see app/[lang]/layout.tsx).

app/layout.tsx
// app/layout.tsx — a Server Component. The display-axis part of this site's own root layout
// (app/[lang]/layout.tsx, which also takes the language segment and mounts ComitorLocaleProvider),
// with the metadata and viewport blocks cut down to one line and the comment translated.
import { ContrastProvider, DensityProvider, FontSizeProvider, PaletteProvider, ThemeProvider } from '@comitor/ui/shell'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({
  subsets: ['latin', 'vietnamese'],
  weight: ['400', '500', '600', '700', '800'],
  display: 'swap',
  variable: '--font-inter',
})

export const metadata: Metadata = { title: 'Comitor Design System' }

/**
 * The five axes know nothing about each other, so the NESTING ORDER is arbitrary.
 * All five anti-flash scripts are rendered by the providers themselves, inside
 * <body>, ahead of the app's markup — nothing goes into <head>.
 *
 * Palette: this site lets the READER choose (the picker in the header), so that every
 * demo can be seen under every palette — hence the provider, on the package default,
 * gold. It does NOT also write data-palette on <html>: that attribute plus a provider
 * without defaultValue renders gold (the provider's script removes the attribute when
 * the final value is gold). No CSP here, so no nonce; add one and pass it to all five.
 *
 * `suppressHydrationWarning` on <html> is mandatory: those five scripts edit this
 * element before React hydrates, so the client HTML never matches the server's.
 */
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en" suppressHydrationWarning className="bg-background">
      <body className={`${inter.variable} font-sans antialiased`}>
        <ThemeProvider>
          <ContrastProvider>
            <DensityProvider>
              <FontSizeProvider>
                <PaletteProvider>{children}</PaletteProvider>
              </FontSizeProvider>
            </DensityProvider>
          </ContrastProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}
  • Nesting order is arbitrary. The five contexts never read one another, so any permutation behaves identically.
  • Language is not a sixth axis. ComitorLocaleProvider belongs in the same root layout, but it writes no attribute on <html>, runs no anti-flash script and keeps nothing in localStorage — the language is the user’s account setting, handed down on the server. It reads none of the five and none of them reads it, so it can wrap them or sit between them. What must be inside it is every control on this page: their strings come from its shell, contrast and displayAxis namespaces. This site renders none, because it is English and English is the default; a Comitor app renders one, as below (Set the language).
  • suppressHydrationWarning on <html> is mandatory. Five inline scripts edit that element before React hydrates. Without the attribute React reports a mismatch on every load of every page; with it, only <html> itself is exempted, not the tree below it.
  • Nothing goes in <head>. The app adds no script tag of its own — see the next section.
  • next-themes must be a single copy. It keeps one context singleton for the whole tree. Two copies in node_modules and the app's useTheme() reads a different provider than the package's — nothing fails to compile, the theme button just stops working.
app/layout.tsx — with the language
// app/layout.tsx in a Comitor app — the four every app mounts, plus the language. PaletteProvider
// (the fifth) only when users may pick the theme colour — see Theme palette above.
import { ComitorLocaleProvider } from '@comitor/ui'
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'
import { getLocale } from 'next-intl/server'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  // The user's own setting, through next-intl — never a hard-coded 'vi'.
  const locale = await getLocale()

  return (
    <html lang={locale} suppressHydrationWarning>
      <body>
        {/* Outside the four or between them: none of the five reads another. */}
        <ComitorLocaleProvider locale={locale}>
          <ThemeProvider>
            <ContrastProvider>
              <DensityProvider>
                <FontSizeProvider>{children}</FontSizeProvider>
              </DensityProvider>
            </ContrastProvider>
          </ThemeProvider>
        </ComitorLocaleProvider>
      </body>
    </html>
  )
}

The anti-flash script lives in the body

A stored choice has to be on <html> before the first paint. React effects run after paint, so an effect is too late: someone who chose high contrast, or large text, would see one frame of the default and then a jump. Each provider therefore renders a tiny synchronous script — ContrastScript, DensityScript, FontSizeScript, PaletteScript — as its own first child. An app does not add these; the provider already did. They are exported only for the case where you build your own provider or need the tag somewhere else in the body.

Do not move the script into <head>

React 19 logs “Encountered a script tag while rendering React component” on every client render if you do, because an inline script is not one of the elements React can hoist — a red console on every page of the app. Inside <body> there is no warning and no loss: the parser executes the script the moment it reaches it, and it stands ahead of all of the app's markup. It is the same place next-themes puts its own script.

The script reads one localStorage key and, if the value is not the default, writes one attribute. Since 3.15.0 the density, font-size and palette scripts also apply the provider’s defaultValue when nothing is stored, and remove the attribute when they land on the package default; every prop value they interpolate is escaped first, so a storageKey containing </script> cannot close the tag. It touches nothing else, it cannot throw (the read is wrapped — a browser with storage blocked simply falls back to the default), and running it twice is a no-op.

The default value writes nothing

Four of the five axes remove their attribute at the default rather than writing it. There is no data-contrast="normal", no data-density="comfortable", no data-font-size="md", no data-palette="gold" — and correspondingly no CSS block for any of them. (Axis 1 is the exception: next-themes writes class="light" or class="dark" either way, which is its own convention.)

For density this is tidiness. For font size it is an accessibility decision. The medium step must not stamp an absolute size onto <html>. A visitor may have already raised the default text size in their browser; declaring font-size: 16px at the root erases that choice — a WCAG 1.4.4 violation committed inside an accessibility feature. Writing nothing at the default leaves the browser's own size intact, and the two other steps are percentages of that size:

[data-font-size="sm"] { font-size: 87.5% }

/* md — no rule at all */

[data-font-size="lg"] { font-size: 112.5% }

Scaling the root size rather than overriding the --text-* tokens is also deliberate. Icons in the package take their size from the spacing scale, not the type scale — roughly 150 places resolve to calc(var(--spacing) * n) — so moving only the text would leave 16px labels beside 16px icons in every menu row, and would break Kbd first. Moving the root moves everything expressed in rem together and keeps the icon-to-text ratio fixed.

Why only axis 1 uses next-themes

The obvious move for axis 2 would have been a second next-themes provider with attribute="data-contrast" and its own storage key. It does not work. next-themes keeps one context singleton for the whole tree, so nesting a second provider means the innermost one wins and the app's useTheme() starts reading the wrong axis. Nothing fails to compile. The light/dark button simply stops responding, and the cause is nowhere near the symptom.

The contrast axis is also simpler than a theme: two values, no system setting, no prefers-color-scheme query. Sixty hand-written lines cover it without paying that price. Axes 3, 4 and 5 would have been near-identical copies of those sixty lines, so they come out of a shared factory instead — same mount-then-read sequence, same mounted flag, same in-body script, same remove-at-default rule.

ContrastProvider was deliberately left off that factory: it is published API with documented behaviour, and rewriting it is a risk with no payoff. The visible trace of that history is the prop name — defaultContrast on the contrast provider, defaultValue on the other three.

What the high-contrast palette is for

The default palette is the approved comitor-ds look, and it is legible on a good monitor in a normal room. It is not legible on a cheap panel, at low screen brightness, or outdoors — several of its pairs sit under the WCAG threshold on purpose, as a design decision rather than an oversight. The high-contrast palette is the measured set: same components, same layout, different values for the CSS variables. It is a choice the user makes in settings, not something the product decides for them.

The two palettes are separate from light and dark. Each palette has its own light build and its own dark build, which is exactly why this cannot be one four-way theme list: a user picks a side of each axis independently.

What this costs a component author

There is no data-contrast: branch anywhere in any component, which is precisely why no component can be forgotten when the palette changes. The price is that colour must go through a role token: --x fills a background, --x-foreground is text on that solid background, and --x-ink is that colour used on the page background. Write bg-gold-300 or text-ash-500 where a role token belongs and the component is pinned to one palette — it compiles, it looks right in the palette you tested, and it is silently wrong in the other.

The full contract, the token tables and the measured ratios are on Foundations · Colour.

Constants and type guards

Never hard-code 'comitor-density' or 'data-contrast' in an app — import them. The four guards are the same functions the providers use to validate what comes back out of storage.

constants
import {
  CONTRAST_ATTRIBUTE, // 'data-contrast'
  CONTRAST_MODES, // ['normal', 'high']
  CONTRAST_STORAGE_KEY, // 'comitor-contrast'
  DEFAULT_DENSITY, // 'comfortable'
  DEFAULT_FONT_SIZE, // 'md'
  DEFAULT_PALETTE, // 'gold' — the PACKAGE default, whatever defaultValue an app passes
  DENSITY_ATTRIBUTE, // 'data-density'
  DENSITY_MODES, // ['comfortable', 'compact']
  DENSITY_STORAGE_KEY, // 'comitor-density'
  FONT_SIZE_ATTRIBUTE, // 'data-font-size'
  FONT_SIZE_MODES, // ['sm', 'md', 'lg']
  FONT_SIZE_STORAGE_KEY, // 'comitor-font-size'
  isContrastMode,
  isDensityMode,
  isFontSizeMode,
  isThemePalette,
  PALETTE_ATTRIBUTE, // 'data-palette'
  PALETTE_STORAGE_KEY, // 'comitor-palette'
  THEME_PALETTES, // ['gold', 'green', 'teal', 'blue', 'indigo', 'violet', 'rose', 'orange', 'graphite']
  type ThemePalette,
} from '@comitor/ui/shell'

// All of the above are readable from a Server Component. They live in modules with
// NO 'use client' directive (contrast-constants.ts, display-axis-constants.ts), and
// shell/index.ts re-exports them straight from those modules rather than through the
// providers. Route a constant through a client module and an RSC reads it back as
// undefined — no error, no warning.
export function readStoredDensity(raw: unknown) {
  return isDensityMode(raw) ? raw : DEFAULT_DENSITY
}

Note what is not on that list: there is no DEFAULT_CONTRAST. Axes 3, 4 and 5 got DEFAULT_DENSITY, DEFAULT_FONT_SIZE and DEFAULT_PALETTE because the factory needs a named default; axis 2 hard-codes 'normal' in its own signature. The toggle labels do have a constant — DEFAULT_CONTRAST_TOGGLE_LABELS — though since 3.0.0 it is only the English table, kept for an app to spread: ContrastToggle itself reads the contrast namespace of ComitorLocaleProvider, and the Vietnamese pair is comitorMessagesVi.contrast.

Resetting and scoping

The four package hooks give you the setters, so a “restore defaults” button is four calls. It is worth having: a user who set large text on a phone and compact rows on a laptop has two independent states to undo, and these settings never leave the device. The button below is live — it resets this site. Light and dark is not in it, because that axis belongs to next-themes and has its own control.

Two products on the same domain would otherwise share these keys, since localStorage is scoped to the origin. Give each one its own key when that is wrong. The key and the starting value are yours to set; the attribute names are not, because styles.css selects on them.

scoped providers
// A second product on the same domain that must not inherit the first one's choices.
// The storage key and the starting value are yours; the attribute names are not, because
// styles.css selects on them.
import type { ReactNode } from 'react'
import { ContrastProvider, DensityProvider, FontSizeProvider } from '@comitor/ui/shell'

export function CrmDisplayProviders({ children }: { children: ReactNode }) {
  // ⚠ Since 3.15.0 the density, font-size and palette scripts are handed the provider's
  // defaultValue, so defaultValue="compact" is right on the first paint of a first visit.
  // The CONTRAST script is not: it takes no default and writes the attribute only when
  // the stored string is 'high'. So defaultContrast="high" still paints one frame of
  // normal contrast on a first visit, until the provider corrects it. From the user's
  // first click onward the stored value drives the script and there is no flash.
  return (
    <ContrastProvider storageKey="crm-contrast" defaultContrast="high">
      <DensityProvider storageKey="crm-density" defaultValue="compact">
        <FontSizeProvider storageKey="crm-font-size">{children}</FontSizeProvider>
      </DensityProvider>
    </ContrastProvider>
  )
}

Three ways to get this wrong

  • Putting a display control inside a settings form. All five controls apply and persist immediately, like the theme button. Mix one into a draft-and-save flow and the “unsaved changes” bar lights up for a change that already took effect, while the toast's Undo rolls back the draft without rolling back the axis — the UI contradicts itself.
  • Rendering a control outside its provider. useContrast, useDensity, useFontSize and usePalette throw with a message naming the provider to add. ThemeToggle is the exception — it needs no shell context at all and works on a sign-in page.
  • Importing a constant through a client module. The RSC boundary is cut per file: every export of a 'use client' module becomes a client-reference proxy when a Server Component reads it, and a string constant comes back undefined with no error and no warning. That is why the constants live in their own directive-free modules and why the shell barrel re-exports them from there rather than through the providers.

ThemeProvider props

A thin wrapper over next-themes with Comitor defaults — ThemeProviderProps is an alias of next-themes' own props type, so every option below can be overridden.

PropTypeDefaultDescription
attribute'class' | 'data-*' | Attribute[]'class'What next-themes writes on <html>. The package default matches @custom-variant dark (&:is(.dark *)) in styles.css — change it and every dark: utility in the package stops resolving.
defaultThemestring'system'Theme when nothing is stored yet.
enableSystembooleantrueFollow prefers-color-scheme when the theme is "system". This is the only axis of the five that reads an OS preference.
disableTransitionOnChangebooleantrueSuppress CSS transitions for one frame while switching, so the whole page does not cross-fade.
storageKeystring'comitor-theme'localStorage key. Same naming as the other four axes.
forcedThemestring—Pins one route to a theme regardless of the stored choice (next-themes).
themesstring[]['light', 'dark']The theme names next-themes writes to <html>. "system" is not in this list — it comes from enableSystem, which appends it to the themes array the hook reports back.
valueRecord<string, string>—Maps a theme name to the attribute value written on <html>.
enableColorSchemebooleantrueAlso sets the color-scheme CSS property so native inputs and scrollbars follow the theme.
noncestring—CSP nonce forwarded to the inline script and style tags.
scriptPropsScriptProps—Extra props on next-themes' own inline script tag.
childrenrequiredReact.ReactNode—The app.

ThemeToggle props

PropTypeDefaultDescription
variant'icon' | 'menu''icon'"icon" is one button that flips light ⇄ dark and carries a tooltip. "menu" is a dropdown with Light / Dark / System — the only one of the two that can reach "system" again once a user has picked a side.
labelsPartial<ThemeToggleLabels>—Four keys picked out of ShellLabels — themeLight, themeDark, themeSystem, toggleTheme — so the button works outside ShellProvider. Unset keys come from the shell namespace of ComitorLocaleProvider (Light, Dark, System, Toggle theme with no provider), so a partial override is fine. Inside AppHeader the shell’s merged labels are passed in for you.
onThemeChange(theme: ThemeChoice) => void—Notify callback, added in 1.8.0 to match onContrastChange — for recording an account default, a toast or an event, not for persistence. Fires ThemeChoice ('light' | 'dark' | 'system'); in variant="icon" it never emits "system", since that button only flips light ⇄ dark.
classNamestring—Merged onto the button (and onto the placeholder shown before mount, so the two are the same size).

ContrastProvider props

PropTypeDefaultDescription
defaultContrast'normal' | 'high''normal'Palette when nothing is stored. Note the name: this axis was hand-written before the density/font-size factory existed, so its prop is defaultContrast, not defaultValue.
storageKeystringCONTRAST_STORAGE_KEY ('comitor-contrast')Change it when one app on a shared domain must keep its own choice.
noncestring—CSP nonce forwarded to the anti-flash script this provider renders. Next stamps its own scripts but React 19 does not stamp author-rendered ones, so under a strict script-src this prop is the only way in.
childrenrequiredReact.ReactNode—The app. The provider renders <ContrastScript /> ahead of it.

ContrastToggle props

PropTypeDefaultDescription
labelsPartial<ContrastToggleLabels>—Two keys, label and description. Its own type rather than part of ShellLabels, because the toggle is usable outside the shell. Unset keys come from the contrast namespace of ComitorLocaleProvider — High contrast, and a one-line description, in English with no provider.
onContrastChange(mode: ContrastMode) => void—Fires after the change is applied and stored — for a toast or an analytics event, not for persistence.
classNamestring—Merged onto the Switch track.

DensityProvider props

PropTypeDefaultDescription
defaultValue'comfortable' | 'compact'DEFAULT_DENSITY ('comfortable')Density when nothing is stored. Named defaultValue, not defaultDensity — this provider comes out of the shared display-axis factory.
storageKeystringDENSITY_STORAGE_KEY ('comitor-density')localStorage key.
noncestring—CSP nonce forwarded to the anti-flash script this provider renders. Next stamps its own scripts but React 19 does not stamp author-rendered ones, so under a strict script-src this prop is the only way in.
childrenrequiredReact.ReactNode—The app. The provider renders <DensityScript /> ahead of it.

DensityToggle props

PropTypeDefaultDescription
labelstring'Compact layout'Visible text next to the switch, and — through the Switch’s htmlFor — its accessible name. Unset, the densityLabel key of the provider’s displayAxis namespace (Bố cục gọn in Vietnamese).
descriptionstring'Reduces vertical spacing between rows in tables, lists and menus.'Secondary line under the label, from densityDescription when unset. Visual only: it is not wired to aria-describedby, so keep the label self-sufficient.
onDensityChange(mode: DensityMode) => void—Notify callback, added in 1.8.0 to match onContrastChange — the value in effect stays in localStorage; this only reports that it moved, for an account default or a toast.
classNamestring—Merged onto the Switch track.

FontSizeProvider props

PropTypeDefaultDescription
defaultValue'sm' | 'md' | 'lg'DEFAULT_FONT_SIZE ('md')Font size when nothing is stored. Same factory as DensityProvider, so the same prop name.
storageKeystringFONT_SIZE_STORAGE_KEY ('comitor-font-size')localStorage key.
noncestring—CSP nonce forwarded to the anti-flash script this provider renders. Next stamps its own scripts but React 19 does not stamp author-rendered ones, so under a strict script-src this prop is the only way in.
childrenrequiredReact.ReactNode—The app. The provider renders <FontSizeScript /> ahead of it.

FontSizeControl props

PropTypeDefaultDescription
labelstring'Font size'aria-label of the radiogroup. There is no visible group heading, so this string is the only name a screen reader hears for the set. Unset, the fontSizeLabel key of the provider’s displayAxis namespace.
optionLabelsRecord<FontSizeMode, string>{ sm: 'Small', md: 'Medium', lg: 'Large' }Visible names of the three radios, keyed by the machine value. Rendered in FONT_SIZE_MODES order — sm, md, lg. Unset, the three flat keys fontSizeSm, fontSizeMd and fontSizeLg of the displayAxis namespace (Nhỏ, Vừa, Lớn in Vietnamese). All three or none: pass the prop and it replaces the set.
onFontSizeChange(mode: FontSizeMode) => void—Notify callback, added in 1.8.0 to match onContrastChange — for persisting an account default elsewhere, not the source of truth, which stays in localStorage.
classNamestring—Merged onto the radiogroup wrapper.

PaletteProvider props

PropTypeDefaultDescription
defaultValueThemePaletteDEFAULT_PALETTE ('gold')Palette when nothing is stored — the product's own colour, if it has one. Since 3.15.0 the anti-flash script applies it on the first paint of a first visit. Named defaultValue, not defaultPalette: the provider comes out of the shared display-axis factory.
storageKeystringPALETTE_STORAGE_KEY ('comitor-palette')localStorage key.
noncestring—CSP nonce forwarded to the anti-flash script this provider renders. Next stamps its own scripts but React 19 does not stamp author-rendered ones, so under a strict script-src this prop is the only way in.
childrenrequiredReact.ReactNode—The app. The provider renders <PaletteScript /> ahead of it.

PalettePicker props

PropTypeDefaultDescription
labelstring'Theme color'aria-label of the radiogroup. There is no visible heading — put one beside it yourself. Unset, the paletteLabel key of the provider’s displayAxis namespace.
palettesreadonly ThemePalette[]THEME_PALETTESWhich swatches to offer, in this order. A product that offers only its own colour and gold passes a subset. A stored value outside the subset still applies; no swatch shows as selected.
optionLabelsPartial<Record<ThemePalette, string>>—Names of the swatches, which carry no text: each name is the swatch’s aria-label and title. Partial, unlike FontSizeControl — a key you leave out falls back to paletteGold … paletteGraphite of the displayAxis namespace.
onPaletteChange(palette: ThemePalette) => void—Notify callback, fired after the change is applied and stored — for an account default or a toast, not for persistence.
classNamestring—Merged onto the radiogroup wrapper.

ContrastScript · DensityScript · FontSizeScript · PaletteScript props

storageKey and nonce on all four; defaultValue on every one but ContrastScript. You will not normally render any of them: each provider already renders its own as its first child, with its own props passed on.

PropTypeDefaultDescription
storageKeystringthe matching *_STORAGE_KEYMust be the same key the provider uses, or the script reads a value nobody wrote. Rendering a script twice is harmless — it is idempotent — it just costs a tag.
defaultValueDensityMode | FontSizeMode | ThemePalettethe package defaultNew in 3.15.0, on DensityScript, FontSizeScript and PaletteScript only — not on ContrastScript. The value applied when nothing is stored; a value outside the axis’s set falls back to the package default. When the value the script lands on IS the package default it removes the attribute. Must match the provider’s defaultValue.
noncestring—CSP nonce for the inline script. Emitted server-side only — the browser blanks the attribute after load, so rendering the real value on the client is a hydration mismatch on every page.

DisplayPrefsSeedScript props

No directive: it renders in a Server Component, straight in the root layout, without a client island.

PropTypeDefaultDescription
seedrequiredPartial<Record<DisplayPrefKey, string | null>> | null | undefined—The account's defaults — theme, contrast, density, fontSize and, since 3.15.0, palette. Typed wide on purpose: the component filters every value against the package's closed sets, so you can pass what you read from a cookie without casting it. An axis that is missing or invalid is left alone; nothing valid at all renders nothing.
noncestring—CSP nonce for the inline script — the same value the providers get.

Accessibility

  • The font-size axis is itself an accessibility feature, so it must not fight the browser. The medium step writes no rule; small and large are percentages of whatever root size the visitor has configured. An absolute size at <html> would override that setting and breach WCAG 1.4.4.
  • FontSizeControl is a radiogroup, not three buttons. role="radiogroup" with aria-checked on each option, so a screen reader announces “2 of 3” rather than three unrelated controls. They are real <button> elements reachable by Tab, not a roving-focus group — three options do not justify the extra dependency.
  • The size buttons stay at text-sm. If they scaled with the setting, pressing “Large” would move the row of buttons out from under the pointer that just pressed it.
  • Overriding a label fixes the visible text and the accessible name together. On ContrastToggle and DensityToggle, label renders a real <label htmlFor>, so the two cannot drift apart — no English accessible name over Vietnamese visible text, which is what WCAG 2.5.3 Label in Name forbids. The description line is visual only, not wired to aria-describedby: keep the label meaningful on its own.
  • ThemeToggle in icon mode names itself with toggleTheme while its tooltip shows themeLight / themeDark. Translate all four keys together, and prefer the menu variant where users need to get back to “System” — the icon variant can only alternate between light and dark.
  • The four axis controls are disabled until their provider has mounted, and ThemeToggle renders a same-size empty placeholder instead of a button for the same span. For the first paint the provider still reports the default, so an enabled control would show the wrong state and let a fast click overwrite a saved preference. The page itself does not flash, because the anti-flash script has already set the attribute — only the control waits.
  • Compact density moves vertical rhythm only. Hit targets keep their horizontal size, and the switch, checkbox and icon dimensions do not shrink — the axis rewrites five padding tokens rather than the global --spacing, which would have dragged 16px icons down to about 13px.
  • PalettePicker is a radiogroup of swatches with no text. Each swatch is a role="radio" button whose palette name is its aria-label and title; the selected one adds a check icon and a ring, so the choice is never carried by colour alone. Put a visible heading beside it — the group’s label is heard, not seen.
  • Storage failure is not an error. In private mode, or with site data blocked, every read and write is wrapped: the axis falls back to its default and the choice simply lasts for the session.

Related