Create a skin
A skin is one CSS file. Set token values under your skin's name in the cml.style layer, add a few scoped rules if you need them, and check the contrast in both modes.
// you'll learn
- Which tokens make a look, and which you can leave at their defaults
- How to write the file:
@layer cml.style,light-dark(),@scope … to ([data-style]) - How to test contrast the way the library tests its own 13 skins
Watch one being built
This is "sunset", the skin written on this page. Each step adds to the file; the demo runs it with the brand and mode of the "preview in" bar.
Invite your team
They get an email with a link that expires in 7 days.
@layer cml.style {
[data-style="sunset"] {
}
}Defaults — a name with no tokens yet looks exactly like minimal.
Name it and open the layer
Pick a lowercase name and write everything inside @layer cml.style, the layer the 13 skins use. That puts your skin above the defaults and the component rules, and leaves your app's unlayered CSS free to override it.
@layer cml.style {
[data-style="sunset"] {
/* tokens go here */
}
}Set the tokens that make the look
No token is required: anything you leave out keeps the minimal default. In practice a look comes from these groups, roughly in this order of impact:
| Group | Tokens to start with |
|---|---|
| Type | --cml-font-body, and --cml-font-display / --cml-font-control if they differ |
| Surfaces and text | --cml-surface, --cml-surface-raised, --cml-fg, --cml-fg-secondary, --cml-fg-muted |
| Lines | --cml-border, --cml-border-control, --cml-border-width |
| Accent | --cml-accent, --cml-accent-fg, --cml-accent-hover: which brand steps you use |
| Shape | --cml-radius-control, --cml-radius-field, --cml-radius-surface |
| Depth | --cml-shadow-control, --cml-shadow-surface, --cml-shadow-popover, --cml-card-shadow |
| Density | --cml-control-height, --cml-field-height, --cml-surface-padding |
Write every color as light-dark(light, dark) so one declaration covers both modes. For the accent, pick brand steps, never a fixed color, and always pair a step with its -fg and -hover: that is what keeps text readable for any brand. The Tokens page lists all of them with their defaults.
Need a fill that is neither surface nor accent? Declare your own token next to the others (neubrutalism has --cml-ink and --cml-highlight) and use it in your scoped rules.
Add scoped rules, if you must
Some looks need structure a token can't express: a divider, a bevel, a different hover. Put those rules in @scope with your skin as the root and to ([data-style]) as the limit, so they never reach a nested island in another skin.
@layer cml.style {
@scope ([data-style="sunset"]) to ([data-style]) {
[data-slot="card-header"] {
border-bottom: var(--cml-border-width) solid var(--cml-border);
}
}
}Every rule you add is a rule you must check in both modes, with reduced motion and in forced colors. Prefer a component token (they are listed under Styling hooks on each component page) over a rule.
Use it
Import the file after base.css and pass the name. styleName takes any name your CSS targets, so TypeScript is happy without a cast:
import "@camaleo/styles/base.css";
import "./sunset.css";
import { StyleProvider } from "@camaleo/react";
<StyleProvider styleName="sunset" mode="system">
<App />
</StyleProvider>
Test the contrast
The library runs every skin × light and dark × five hard brand colors (blue, yellow, cyan, coral, gray) through axe and a set of contrast checks. Do the same for yours. The checks read the computed tokens, so they need a real browser (Vitest browser mode or Playwright):
import "@camaleo/styles/base.css";
import "./sunset.css";
import { StyleProvider } from "@camaleo/react";
import { wcagContrast } from "culori";
import { expect, it } from "vitest";
import { render } from "vitest-browser-react";
import { Screens } from "./screens";
// Resolves a token to a color by letting the browser compute it.
function token(scope: Element, name: string) {
const probe = document.createElement("span");
probe.style.color = `var(${name})`;
scope.append(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
}
for (const mode of ["light", "dark"] as const)
for (const brand of ["#2563eb", "#facc15", "#22d3ee", "#ff7f50", "#808080"])
it(`sunset / ${mode} / ${brand}`, async () => {
// Your own screens, in the skin, mode and brand under test.
const screen = await render(
<StyleProvider styleName="sunset" mode={mode} brand={brand}>
<Screens />
</StyleProvider>,
);
const root = screen.container.firstElementChild as Element;
const surface = token(root, "--cml-surface");
expect(wcagContrast(token(root, "--cml-fg-muted"), surface)).toBeGreaterThanOrEqual(4.5);
expect(wcagContrast(token(root, "--cml-border-control"), surface)).toBeGreaterThanOrEqual(3);
expect(wcagContrast(token(root, "--cml-focus-ring"), surface)).toBeGreaterThanOrEqual(3);
});
Check --cml-danger, --cml-success, --cml-warning and --cml-info against your surface too (4.5:1): their defaults were tuned for white and near-black, and a tinted surface can push them under. Sunset's warm surfaces keep them all above 4.7:1.
The complete file
The whole skin from this page, ready to copy:
/* Sunset: warm paper, ink outlines, round controls and a hard offset shadow. */
@layer cml.style {
[data-style="sunset"] {
--cml-font-body: "Fraunces", Georgia, serif;
--cml-surface: light-dark(#fff7ef, #1c1410);
--cml-surface-raised: light-dark(#fffdf9, #261b15);
--cml-fg: light-dark(#2b1a14, #f6e9df);
--cml-fg-secondary: light-dark(#5a4034, #d9c4b6);
--cml-fg-muted: light-dark(#7a5a4a, #b39a8a);
--cml-border: var(--cml-fg);
--cml-border-control: light-dark(#8a6a58, #9a7f6e);
--cml-accent: light-dark(var(--cml-brand-700), var(--cml-brand-300));
--cml-accent-fg: light-dark(var(--cml-brand-700-fg), var(--cml-brand-300-fg));
--cml-accent-hover: light-dark(var(--cml-brand-700-hover), var(--cml-brand-300-hover));
--cml-border-width: 2px;
--cml-radius-control: 20px;
--cml-radius-field: 12px;
--cml-radius-surface: 24px;
--cml-card-shadow: 6px 6px 0 var(--cml-border);
--cml-shadow-popover: 4px 4px 0 var(--cml-border);
}
@scope ([data-style="sunset"]) to ([data-style]) {
[data-slot="card-header"] {
border-bottom: var(--cml-border-width) solid var(--cml-border);
}
}
}Before you ship it
- Text at least 4.5:1 and control borders and the focus ring at least 3:1, in light and dark
- The five hard brand colors tried in both modes
- The focus ring visible on every surface you added
- Scoped rules checked with reduced motion and forced colors
- The fonts loaded wherever the skin is used