Skins & switching
Thirteen complete design languages over the same components. Load the CSS, wrap the app in a StyleProvider and change one word to change all of it.
// you'll learn
- Which CSS files and fonts each of the 13 skins needs
- What
styleName,modeandbranddo, and which of them inherit - How to nest skins and switch them at runtime without losing state
Try it
Type in the note, like it a few times, then pick another skin. The components re-render with new CSS; nothing is mounted again, so the text and the count stay.
Weekly digest
Import the CSS
base.css holds the tokens, the component rules and the layer order. Import it once, then one file per skin you use. minimal is the default and needs no file of its own.
import "@camaleo/styles/base.css";
import "@camaleo/styles/neubrutalism.css";
import "@camaleo/styles/editorial.css";
If people can pick the skin, import every skin you offer up front. A skin whose file is missing falls back to the minimal defaults, with no error.
Load the fonts
Fonts are not bundled. Each skin names its families in --cml-font-* tokens with system fallbacks, so it still works without them, just less like itself. Load only the ones of the skins you ship:
| Skin | Fonts |
|---|---|
| minimal | Inter |
| swiss | Archivo |
| editorial | Newsreader (text), Fraunces (titles), Inter (controls and labels) |
| progressive | Figtree |
| material | Roboto Flex |
| cupertino | Inter |
| glass | Plus Jakarta Sans |
| neumorphism | Manrope |
| clay | Nunito, Fredoka (titles) |
| neubrutalism | Space Grotesk |
| retro | IBM Plex Sans |
| retro-pixel | Pixelify Sans, Press Start 2P (large titles, basic Latin only) |
| y2k | Exo 2, Audiowide (large titles) |
The CSS asks for the families by name, so a stylesheet link or a self-hosted @font-face works as is. With next/font, point the token at the font's variable:
/* app/layout.tsx: const grotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-grotesk" }) */
[data-style="neubrutalism"] {
--cml-font-body: var(--font-grotesk), system-ui, sans-serif;
}Wrap the app
StyleProvider renders a <div> with data-style, data-mode and the brand tokens inline. Everything inside it, including dialogs and menus rendered in a portal, wears the skin.
import { StyleProvider } from "@camaleo/react";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<StyleProvider styleName="neubrutalism" mode="system" brand="#3fa34d">
{children}
</StyleProvider>
);
}
| Prop | Values | Inherits from a parent provider? |
|---|---|---|
styleName | One of the 13 names; minimal by default | No: a nested provider without it is minimal |
mode | light, dark or system | Yes, when left out (the root follows the system) |
brand | Any CSS color | Yes, when left out |
The 13 skins
Every skin renders the same DOM: the matrix test compares the markup of all 13 and fails if one differs. Only CSS changes, which is why a switch is cheap.
| Skin | In one line |
|---|---|
| minimal | Neutral and quiet, the defaults every other skin starts from |
| swiss | One grotesque, 1px ink rules, square corners, no shadows |
| editorial | Paper, ink and hairlines, with magazine serifs |
| progressive | An installable-app look: warm grey canvas, 44px touch targets |
| material | Tonal surfaces, pill buttons, filled and outlined fields |
| cupertino | Grouped grey background, white cells, continuous corners |
| glass | Frosted translucent layers over a soft blurred backdrop |
| neumorphism | One surface shaped by light: raised actions, sunken inputs |
| clay | Puffy pastel shapes that look moulded from clay |
| neubrutalism | 2px ink outlines and hard, unblurred shadows |
| retro | Classic desktop chrome with four-colour bevels |
| retro-pixel | 8-bit: thick ink borders, stepped corners, pixel fonts |
| y2k | Glossy jelly buttons, brushed chrome and pinstripes |
material is inspired by Material Design and cupertino by the Human Interface Guidelines. Camaleo is not affiliated with Google or Apple and uses none of their fonts, icons or trademarks.
Nesting
A nested StyleProvider is an island: every token is declared again on its element, so it starts from clean defaults instead of mixing with its parent. The parent's mode and brand still reach it unless you set them.
Your app, in the skin of the preview bar
A retro island: it starts from clean tokens
Switching at runtime
Keep the skin in state and pass it down. Changing styleName changes one attribute on the same element, so React keeps every component and its state. Don't put the skin in a key: that remounts the tree and resets forms.
"use client";
import { useState } from "react";
import { STYLE_NAMES, type StyleName, StyleProvider } from "@camaleo/react";
export function App({ children }: { children: React.ReactNode }) {
const [skin, setSkin] = useState<StyleName>("minimal");
return (
<StyleProvider styleName={skin}>
<select aria-label="Skin" value={skin} onChange={(e) => setSkin(e.target.value as StyleName)}>
{STYLE_NAMES.map((name) => <option key={name}>{name}</option>)}
</select>
{children}
</StyleProvider>
);
}
The whole page, from the server
To skin <html> itself (page background, scrollbars), spread styleScopeProps on it. It is server-safe, so a root layout can call it without a client component.
import { styleScopeProps } from "@camaleo/react/style-scope";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" {...styleScopeProps({ styleName: "editorial", mode: "system" })}>
<body>{children}</body>
</html>
);
}
Popups render in a portal outside your tree. They keep the skin through React context, so render them inside a StyleProvider (or under an <html> with styleScopeProps), never in a separate React root.
Before you ship it
base.cssimported once, before the skin files- One CSS file for every skin people can pick
- The fonts of those skins loaded
- The skin kept in state, never in a
key