Theming

Four theming axes (light/dark, palette, glass surface, density) on one model: ThemeProvider carries scheme, palette and glass on every platform; on the web, helpers persist all four choices and update the CSS handoff attributes.

Native (ThemeProvider)

On iOS and Android, wrap the app once in ThemeProvider. It follows the OS appearance by default; force a scheme with the dark / light boolean, and the material with glass / solid. Both axes are flat booleans like every Canvas axis: pass at most one per axis, omit both for the default (the OS appearance, the platform material). No CSS and no <html> attributes are involved on native.
tsx
import { ThemeProvider } from "@nannier/canvas";
// Wrap the app once. ThemeProvider follows the OS appearance by default;
// pass the dark / light boolean to force a scheme, mint for the mint palette,
// and glass for the material.
export function App() {
return (
<ThemeProvider dark glass>
<Screens />
</ThemeProvider>
);
}
Read the active theme anywhere under the provider with useTheme().
tsx
import { useTheme } from "@nannier/canvas";
// Read the active theme anywhere under the provider.
const { scheme, palette, surface, tokens, dark } = useTheme();
// scheme: "light" | "dark" palette: "blush" | "mint"
// surface: "solid" | "glass" tokens: active color tokens
// dark: scheme === "dark"

Typefaces and nested providers

Hand the faces the app registered to the root provider's fonts prop, once: one family that carries every weight, or a face per weight. They are what the app loaded, not a theme choice, so a nested ThemeProvider that omits fonts keeps the nearest parent's faces, in the overlays it opens too. One that passes fonts uses its own map in place of the parent's (the roles are not merged), and an empty map returns that subtree to the system face.
No other theme axis is inherited. A nested provider resolves its scheme, palette, surface and tokens from its own props: omitting dark / light follows the OS appearance, omitting mint paints blush, and omitting tokens paints its palette unbranded. That is how a subtree shows a palette or brand of its own, as the palette previews on this page do. Pass the same tokens constant again to carry a rebrand into a nested provider, and pass both dark={dark} and light={!dark}, with dark read from useTheme(), to follow the parent's scheme (with dark alone, a light parent leaves the nested provider on the OS appearance).
tsx
import { ThemeProvider, useTheme } from "@nannier/canvas";
import { FONTS, BRAND } from "./theme"; // module constants
// Register the faces once, on the root provider.
export function App() {
return (
<ThemeProvider fonts={FONTS} tokens={BRAND}>
<Screens />
</ThemeProvider>
);
}
// A nested provider keeps the root's faces. Its scheme, palette, surface and
// tokens come from its own props, so pass what it should carry on.
function GlassPanel({ children }) {
const { dark } = useTheme();
return (
<ThemeProvider dark={dark} light={!dark} glass tokens={BRAND}>
{children}
</ThemeProvider>
);
}

Light / Dark Mode

Light mode is the default. On the web, dark mode is the dark class on <html>, which the helpers toggle so the token layer flips every color token for anything styled with the CSS variables. Mirror the class into ThemeProvider's scheme so the components follow (the Integration page shows the hook; the value form exists exactly for a held value like that). (On native, pass the dark / light boolean to ThemeProvider instead.)
tsx
<!-- Light (default) -->
<html>
<!-- Dark -->
<html class="dark">

Clear text fields

In glass mode, web Input, Textarea, Autocomplete, InputOTP, Stepper, PhoneInput and DataTable editors use a clear material: Dark Factory's field well, a light tint and a hairline with no blur, so the page reads through unsoftened. Text, selection and the caret stay sharp. The fields stay still when clicked or typed into. Native fields keep their platform material. Bare and underlined editors retain their existing shape.

Web helpers

tsx
import { getTheme, setTheme, toggleTheme } from "@nannier/canvas";
getTheme(); // "light" | "dark"
setTheme("dark"); // applies .dark to <html>, persists to localStorage
toggleTheme(); // switches and returns the new theme

Respecting system preference

On the web the helpers do not auto-detect prefers-color-scheme. Wire it yourself:
tsx
import { setTheme } from "@nannier/canvas";
// Web only. On native, ThemeProvider already follows the OS appearance.
const mq = window.matchMedia("(prefers-color-scheme: dark)");
setTheme(mq.matches ? "dark" : "light");
mq.addEventListener("change", (e) => setTheme(e.matches ? "dark" : "light"));

Palettes

The light scheme comes in two Dark Factory palettes: blush, the default, and mint. Pass the mint boolean to ThemeProvider for mint and omit it for blush. Dark Factory ships one dark palette, so dark wins over mint: a dark provider paints the same colors whichever light palette it names. useTheme() reports the requested palette as palette, and mintColors and colorsFor(palette, scheme) expose the token sets.
tsx
import { ThemeProvider } from "@nannier/canvas";
// Blush is the light default; mint is the light alternative. Dark Factory
// has one dark palette, so dark wins: <ThemeProvider dark mint> paints dark.
export function App() {
return (
<ThemeProvider mint>
<Screens />
</ThemeProvider>
);
}
Blush
Sync
Mint
Sync
Both previews render in the light scheme, where the two palettes differ.

Web helpers

The helpers persist the choice and set data-palette on <html>, which the CSS handoff reads; the dark class still wins over it. Feed the same choice to ThemeProvider so React Native components follow it.
tsx
import { ThemeProvider, getPalette, setPalette } from "@nannier/canvas";
getPalette(); // "blush" | "mint", the persisted choice
setPalette("mint"); // persists it, and sets data-palette="mint" on <html>
setPalette("blush"); // persists it, and removes the attribute
// The CSS handoff reads data-palette, and .dark still wins over it. Pass the
// same choice to ThemeProvider so React Native components follow it.
<ThemeProvider mint={getPalette() === "mint"}>...</ThemeProvider>

Glass Surface

Glass is a theme preference, not a prop to add to every component. The material contract distinguishes stable content glass from functional Liquid Glass. Reading surfaces and text-entry wells stay still; floating navigation, controls and overlays can use liquid material where their role calls for it. Labels, images, chart marks and layout wrappers do not become separate glass panels. Tint density and custom motion are separate decisions, and native Liquid Glass does not need added wobble.
Omit both surface booleans to follow the platform default: glass on supported iOS 26+ and solid elsewhere. Use glass or solid on ThemeProvider to choose an appearance. Solid means opaque fill, readable foreground, boundaries and elevation. Accessibility preferences and available native or browser material determine the rendered result. Switching appearance must preserve focus, values, open controls and scrolling. Components read this preference from the provider, not from CSS.
tsx
import { ThemeProvider, getSurface } from "@nannier/canvas";
// Web: the same provider and the same booleans. getSurface() reads the choice
// setSurface() persisted (see the helpers below), so it survives a reload.
export function App() {
const surface = getSurface(); // "solid" | "glass"
return (
<ThemeProvider glass={surface === "glass"} solid={surface === "solid"}>
<Screens />
</ThemeProvider>
);
}

What changes

  • The kit's shared material rendering owns material selection and clipping. Native Liquid Glass, native frost and the browser's frost are different capabilities; a browser preview of an iOS skin does not prove native rendering
  • No semantic token changes: popover and card keep the same opaque values they carry in solid mode. Glass adds its own fills, glass-tint, glass-tint-content, glass-tint-control and glass-tint-dense, painted under the material by the surfaces of each layer
  • Brand and status meanings remain readable in every material. Check contrast against actual backgrounds, including scrolling content; a tint token or a decorative rim alone does not establish it
  • Solid surfaces retain their full opaque treatment without glass capture. Reduce Transparency and Increase Contrast require readable opaque treatment; Reduce Motion removes nonessential movement without requiring opacity by itself
  • Android blur needs a safe live backdrop target. The optional @nannier/canvas-blur integration on Android 12+ with Expo SDK 57 enables capture only while glass needs it. OverlayProvider supplies safe overlay targets. Missing or unsafe material uses the complete solid skin
  • The CSS handoff always carries the web material's tints, blur and hairline; data-surface marks glass mode and gates the accessibility and print fallbacks that turn the material opaque. It paints no page backdrop of its own and supplies no native material

Web helpers

The helpers persist the choice and update the CSS handoff attributes. Feed the same choice to ThemeProvider so React Native components follow it.
tsx
import { getSurface, setSurface } from "@nannier/canvas";
getSurface(); // "solid" | "glass", the persisted choice
setSurface("glass"); // persists it, and sets data-surface="glass" on <html>
setSurface("solid"); // persists it, and removes the attribute
// The CSS handoff reads data-surface for the material mode. Also pass the
// choice to ThemeProvider so React Native components follow it.

Density

Density controls spacing in content areas and data tables, and it is a per-component choice on every platform: pass the compact or comfortable boolean to the components that support it (for example Card and DataTable). The separate CSS handoff supports an app-wide density attribute for the card and table spacing tokens it defines.
tsx
// Density is per component, on every platform; omit both for the default.
<Card compact>...</Card>
<Card comfortable>...</Card>
<DataTable compact columns={columns} rows={rows} />

Web helpers

The helpers persist an app-wide preference (compact, regular, comfy) and set data-density on <html>, which the CSS handoff reads. Read the stored value back and map it to component booleans to apply that preference to React Native components.
tsx
import { getDensity, setDensity } from "@nannier/canvas";
getDensity(); // "compact" | "regular" | "comfy", the persisted preference
setDensity("compact"); // persists it, and sets data-density="compact" on <html>
setDensity("regular"); // persists it, and removes the attribute
// The CSS handoff adjusts supported card and table spacing tokens. Map the
// preference to component booleans (compact / comfortable) for React Native.

Combining Axes

All four axes are independent and composable. Scheme, palette and surface are ThemeProvider props on every platform; density is per component. The web helpers persist all four choices and update the CSS handoff through the dark class, data-palette, data-surface, and data-density. Keep provider state and component booleans in sync with those choices.
tsx
// One provider carries scheme, palette and surface; density rides each component.
<ThemeProvider light mint glass>
<Card compact>...</Card>
</ThemeProvider>
tsx
import { setTheme, setPalette, setSurface, setDensity } from "@nannier/canvas";
setTheme("dark"); // flips .dark on <html>: the CSS token layer re-themes
setPalette("mint"); // sets data-palette on <html>; .dark still wins
setSurface("glass"); // updates CSS handoff; also sync into ThemeProvider
setDensity("compact"); // updates CSS spacing; also map to component booleans