// preview in
saved for every page

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.

sunset.css
@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.

sunset.css
@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:

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

TIP

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.

sunset.css
@layer cml.style {
  @scope ([data-style="sunset"]) to ([data-style]) {
    [data-slot="card-header"] {
      border-bottom: var(--cml-border-width) solid var(--cml-border);
    }
  }
}
CAREFUL

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:

app/providers.tsx
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):

sunset.test.tsx
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.css
/* 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
Was this page helpful? · Edit this page on GitHub