// preview in
saved for every page

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, mode and brand do, 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.

minimal · light · #3fa34d

Weekly digest

Likes: 3

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.

app/layout.tsx
import "@camaleo/styles/base.css";
import "@camaleo/styles/neubrutalism.css";
import "@camaleo/styles/editorial.css";
TIP

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:

SkinFonts
minimalInter
swissArchivo
editorialNewsreader (text), Fraunces (titles), Inter (controls and labels)
progressiveFigtree
materialRoboto Flex
cupertinoInter
glassPlus Jakarta Sans
neumorphismManrope
clayNunito, Fredoka (titles)
neubrutalismSpace Grotesk
retroIBM Plex Sans
retro-pixelPixelify Sans, Press Start 2P (large titles, basic Latin only)
y2kExo 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/fonts.css
/* 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.

app/providers.tsx
import { StyleProvider } from "@camaleo/react";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <StyleProvider styleName="neubrutalism" mode="system" brand="#3fa34d">
      {children}
    </StyleProvider>
  );
}
PropValuesInherits from a parent provider?
styleNameOne of the 13 names; minimal by defaultNo: a nested provider without it is minimal
modelight, dark or systemYes, when left out (the root follows the system)
brandAny CSS colorYes, 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.

SkinIn one line
minimalNeutral and quiet, the defaults every other skin starts from
swissOne grotesque, 1px ink rules, square corners, no shadows
editorialPaper, ink and hairlines, with magazine serifs
progressiveAn installable-app look: warm grey canvas, 44px touch targets
materialTonal surfaces, pill buttons, filled and outlined fields
cupertinoGrouped grey background, white cells, continuous corners
glassFrosted translucent layers over a soft blurred backdrop
neumorphismOne surface shaped by light: raised actions, sunken inputs
clayPuffy pastel shapes that look moulded from clay
neubrutalism2px ink outlines and hard, unblurred shadows
retroClassic desktop chrome with four-colour bevels
retro-pixel8-bit: thick ink borders, stepped corners, pixel fonts
y2kGlossy 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.

minimal · light · #3fa34d

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.

skin-switcher.tsx
"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.

app/layout.tsx
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>
  );
}
CAREFUL

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.css imported 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
Was this page helpful? · Edit this page on GitHub