Shell

Display Axes

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

Four axes, four 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 palette, 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 four 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.

2Colour 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.

The controls, live

These are real

This documentation site runs all four 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 three axes back to their defaults.

Appearance

Light, dark, or follow the system.

Deepens borders, switches and secondary text. Useful on a dim screen or outdoors.

Reduces the vertical rhythm of table rows, list items and menus.

Font size

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.

Reading an axis in your own code

Three 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 three 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…
Colour palettereading…reading…
Layout densityreading…reading…
Font sizereading…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 three 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 four 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. 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 → a new device seeds localStorage from it once, at sign-in, through defaultValue.

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".

Wiring all four

Every provider belongs at the root layout, above AppShell rather than inside it — sign-in pages, marketing pages and error pages need the same appearance as the app. The layout itself stays a Server Component. What follows is this site's own layout, with only the metadata block shortened.

app/layout.tsx
// app/layout.tsx — a Server Component. This site's own root layout, with the metadata
// and viewport blocks cut down to one line; everything below them is verbatim.
import { ContrastProvider, DensityProvider, FontSizeProvider, 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 four axes know nothing about each other, so the NESTING ORDER is arbitrary.
 * All four anti-flash scripts are rendered by the providers themselves, inside
 * <body>, ahead of the app's markup — nothing goes into <head>.
 *
 * `suppressHydrationWarning` on <html> is mandatory: those four 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>{children}</FontSizeProvider>
            </DensityProvider>
          </ContrastProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}
  • Nesting order is arbitrary. The four contexts never read one another, so any permutation behaves identically.
  • suppressHydrationWarning on <html> is mandatory. Four 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.

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 — 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. 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

Three of the four 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" — 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 and 4 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 two.

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 three 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'
  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,
} 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 and 4 got DEFAULT_DENSITY and DEFAULT_FONT_SIZE 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.

Resetting and scoping

The three package hooks give you the setters, so a “restore defaults” button is three 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 }) {
  // ⚠ Moving a DEFAULT away from the package default costs the anti-flash script on a
  // first visit, and only then. Each script is hard-wired to the PACKAGE default and
  // knows nothing about the values below: the contrast script writes the attribute only
  // when the stored string is 'high', the other two only when it differs from
  // 'comfortable' / 'md'. So with nothing stored yet none of them writes anything, and
  // the first paint is the package default until the provider corrects it. From the
  // user's first click onward the stored value drives the script and there is no flash.
  // Changing only `storageKey` avoids the question entirely.
  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 four 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 and useFontSize 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 four 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 three axes.
forcedThemestringPins 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.
noncestringCSP nonce forwarded to the inline script and style tags.
scriptPropsScriptPropsExtra props on next-themes' own inline script tag.
childrenrequiredReact.ReactNodeThe 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>DEFAULT_SHELL_LABELSFour keys picked out of ShellLabels — themeLight, themeDark, themeSystem, toggleTheme — so the button works outside ShellProvider. Spread over the defaults, so a partial override is fine.
onThemeChange(theme: ThemeChoice) => voidNotify 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.
classNamestringMerged 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.
noncestringCSP 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.ReactNodeThe app. The provider renders <ContrastScript /> ahead of it.

ContrastToggle props

PropTypeDefaultDescription
labelsPartial<ContrastToggleLabels>DEFAULT_CONTRAST_TOGGLE_LABELSTwo keys, label and description. Its own type rather than part of ShellLabels, because the toggle is usable outside the shell.
onContrastChange(mode: ContrastMode) => voidFires after the change is applied and stored — for a toast or an analytics event, not for persistence.
classNamestringMerged 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.
noncestringCSP 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.ReactNodeThe app. The provider renders <DensityScript /> ahead of it.

DensityToggle props

PropTypeDefaultDescription
labelstring'Bố cục gọn'Visible text next to the switch, and — through the Switch’s htmlFor — its accessible name.
descriptionstring'Giảm khoảng cách dọc giữa các dòng trong bảng, danh sách và menu.'Secondary line under the label. Visual only: it is not wired to aria-describedby, so keep the label self-sufficient.
onDensityChange(mode: DensityMode) => voidNotify 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.
classNamestringMerged 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.
noncestringCSP 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.ReactNodeThe app. The provider renders <FontSizeScript /> ahead of it.

FontSizeControl props

PropTypeDefaultDescription
labelstring'Cỡ chữ'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.
optionLabelsRecord<FontSizeMode, string>{ sm: 'Nhỏ', md: 'Vừa', lg: 'Lớn' }Visible names of the three radios, keyed by the machine value. Rendered in FONT_SIZE_MODES order — sm, md, lg.
onFontSizeChange(mode: FontSizeMode) => voidNotify callback, added in 1.8.0 to match onContrastChange — for persisting an account default elsewhere, not the source of truth, which stays in localStorage.
classNamestringMerged onto the radiogroup wrapper.

ContrastScript · DensityScript · FontSizeScript props

The same two props on all three. You will not normally render any of them: each provider already renders its own as its first child.

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.
noncestringCSP 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.

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 three 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.
  • 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