Theming

One seed color themes every Chameleon component, in light and dark, at the contrast you choose. Color, type, and shape each have a tab below, and Tokens is the reference for the names you set.

Theming options⁠

ColorTypographyShapeTokens

Chameleon generates a complete color theme from one seed color. Every component paints its surfaces and marks with named color roles from that theme, never a raw hex value.

Generate a theme⁠

generateThemeCssFromHex turns a seed into a stylesheet of --ct-sys-color-* properties. Adopt it once, and every component uses it:

import { generateThemeCssFromHex } from "@materia-technologies-inc/chameleon-components-web";

const css = generateThemeCssFromHex("#6750a4");
const sheet = new CSSStyleSheet();
sheet.replaceSync(css.system);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
content_copy

The system stylesheet follows the operating system's light or dark setting, so you don't maintain a separate dark theme. The result also carries light and dark, each a block of the same properties for one scheme. Apply one to a subtree to pin that region.

The seed can also be an HCT color, with generateThemeCssFromHct, or an ARGB number, with generateThemeCssFromArgb.

Options⁠

Pass an options object that names only the fields you change:

import { generateThemeCssFromHex, ColorThemeVariant } from "@materia-technologies-inc/chameleon-components-web";

const css = generateThemeCssFromHex("#6750a4", {
  variant: ColorThemeVariant.Vibrant,
  contrast: 0.5,
});
content_copy
Option What it sets Default
variant How Chameleon derives the palette from the seed ColorThemeVariant.Expressive
contrast From -1 to 1; the standard levels are 0, 0.5, and 1 0
spec The Material color specification ColorSpecification.MaterialExpressive2025
selector The selector the system stylesheet targets :root
compat Whether system also works in browsers without light-dark() true

The variants give the same seed a different character. Each is a ColorThemeVariant constant or its string value:

  • neutral and monochrome: restrained, for editorial layouts
  • tonal-spot: the classic Material 3 look
  • content and fidelity: stay close to the exact seed hue
  • expressive and vibrant: energetic, for hero sections and launches
  • fruit-salad and rainbow: several hues, best used sparingly
  • chord: three hues, the seed plus two more, 80 and 160 degrees around the color wheel. Chord is added by Chameleon, while all other themes come from Material.

A higher contrast regenerates the scheme rather than darkening it. Chameleon computes every on- role against its own background, so text stays legible at every level, in every variant.

With compat on, system declares the light values, then the dark values for prefers-color-scheme: dark, then both together through light-dark() where the browser supports it. There, color-scheme: dark on any element darkens its subtree. Set compat: false for a smaller stylesheet that needs light-dark(), which every major browser has supported since 2024.

For the raw values instead of a stylesheet, generateDynamicThemeFromHex returns six maps of token name to hex: light and dark, each at standard, medium, and high contrast.

Chameleon's color utilities compute all of this. They build on Google's Material Color Utilities and give the same results.

Follow the user's contrast setting⁠

generateThemeSheet builds a stylesheet that can also follow the user's contrast setting. Give it two contrast levels, and it adds the higher one under prefers-contrast: more, so the page switches when the user asks their operating system for more contrast:

import { generateThemeSheet, ContrastLevel } from "@materia-technologies-inc/chameleon-components-web";

const sheetCss = generateThemeSheet(
  [{ selector: ":root", seed: "#6750a4" }],
  { contrasts: [ContrastLevel.Standard, ContrastLevel.High] },
);
content_copy

Adopt sheetCss the way the first example adopts css.system. A stylesheet holds two levels at most: the one the page starts at, and the one prefers-contrast: more switches to. generateThemeCssFromHex builds one level only.

The accent families⁠

The roles follow the Material 3 color system. Three accent families carry your brand emphasis, in descending prominence: primary, secondary, and tertiary. Each is a complete set, not a single swatch:

  • the base role (primary): a solid, high-emphasis fill
  • its on- role (on-primary): text and icons on the base
  • a container role (primary-container): a softer, lower-emphasis fill
  • the container's on- role (on-primary-container): text and icons on the container

Use primary for the most important action, secondary for supporting emphasis, and tertiary for accents that break up a primary-heavy layout.

The status families⁠

Four status families carry meaning, in the same base, on-, container, and on-container shape: error, information, success, and warning. Use them for feedback, such as a failed field, an informational banner, a confirmation, or a caution, and never for decoration.

Surfaces and depth⁠

Backgrounds come from the surface roles. background is the page, and surface and surface-variant are the default component grounds. Depth comes from stepping through the surface-container tiers, from surface-container-lowest through -low, -container, and -high to -highest, each a touch more prominent than the last. surface-dim and surface-bright bracket the range for full-bleed grounds.

A raised card is a higher container tier, not a drop shadow. It re-themes with the seed, reads correctly in dark mode, and never muddies contrast.

Lines and inversion⁠

outline draws prominent borders and dividers, and outline-variant draws quieter separators. inverse-surface and its on- role flip the scheme. Use them for elements that must stand apart from the page, such as a snackbar.

The pairing rule⁠

An on- role only ever sits on its matching base. on-primary goes on primary, on-surface on a surface, and on-error-container on error-container. Chameleon computes every on- role for contrast against its own base, so if you follow the rule, text stays legible in every variant, light or dark.

.card {
  background-color: var(--ct-sys-color-surface-container-high);
  color: var(--ct-sys-color-on-surface);
}
.card .cta {
  background-color: var(--ct-sys-color-primary);
  color: var(--ct-sys-color-on-primary);
}
.card .note {
  background-color: var(--ct-sys-color-error-container);
  color: var(--ct-sys-color-on-error-container);
}
content_copy

Write your own CSS with roles, correctly paired, and never a literal color. Your interface then recolors the moment the seed changes.

Chameleon's type scale is a small, fixed set of roles, five styles in three sizes each. Choose a role for what a piece of text is, and the role sets its size, weight, line height, and tracking together. A theme chooses two fonts, and every role uses one of them.

The scale⁠

There are five roles, each in three sizes:

Role Sizes Use it for
display large, medium, small The largest text on a page, such as hero headings
headline large, medium, small Section headings and prominent short text
title large, medium, small Card titles, dialog titles, and subheadings
body large, medium, small Running prose: paragraphs and descriptions
label large, medium, small Buttons, chips, captions, and other interface text

Apply it⁠

Each role and size is a set of tokens, --ct-sys-typescale-<role>-<size>-*, for the size, weight, line height, tracking, and font. Read them in your own CSS:

.callout {
  font-family: var(--ct-sys-typescale-title-large-font);
  font-size: var(--ct-sys-typescale-title-large-size);
  line-height: var(--ct-sys-typescale-title-large-line-height);
  font-weight: var(--ct-sys-typescale-title-large-weight);
  letter-spacing: var(--ct-sys-typescale-title-large-tracking);
}
content_copy

Fonts⁠

A theme sets two fonts. The brand font sets the display, headline, and large title roles. The plain font sets the medium and small titles, body text, and labels. Each is one custom property. Set it on the root, or on any element whose contents should use it:

:root {
  --ct-ref-typeface-brand: "Fraunces", Georgia, serif;
  --ct-ref-typeface-plain: "Inter", system-ui, sans-serif;
}
content_copy
Font Property Roles
Brand --ct-ref-typeface-brand display, headline, title large
Plain --ct-ref-typeface-plain title medium and small, body, label

Every role, and every component's text, reads one of the two, so changing them changes the whole interface. Both default to Noto Sans. Load the font files the way you load any web font: Chameleon only names the families. To give each theme its own pair, set them inside each theme's selector.

Heading level is not type style⁠

An element's heading level is about document structure, and its type style is about how it looks. A visually large hero might be an <h1> styled as display small, while a small section label might still need to be an <h2> for the outline, styled as a label.

Choose the heading level for a correct document outline, with one <h1> and no skipped levels, and the type style for the weight the design calls for. Don't use a bigger heading tag to get bigger text: that breaks the outline screen readers rely on.

Chameleon draws the smooth superellipse corners that Material 3 Expressive calls for, such as the continuous squircle, rather than plain rounded rectangles. It draws them the same way in every browser.

Superellipse corners⁠

A superellipse corner is one of a family of curves between a sharp right angle and a full ellipse. A high parameter gives the plump, continuous squircle; a low one makes the corner scoop inward. CSS is standardizing these shapes through the corner-shape property, and Chameleon follows its model: a squircle is superellipse(2), a bevel is superellipse(0), and a scoop is superellipse(-1).

Not every browser supports corner-shape yet. Chameleon draws the corners itself, so a component's corners look the same in Safari and Firefox as they do in Chrome.

Set the shape⁠

Corner shape is a theme token, --ct-sys-corner-shape. Set it once to restyle every component that reads it. To change one component, set its own --ct-<component>-corner-shape token:

/* Every component */
:root {
  --ct-sys-corner-shape: squircle;
}

/* Just the buttons in the hero */
.hero {
  --ct-button-corner-shape: superellipse(3);
}
content_copy

The named values map onto the specification's superellipse parameter, from concave to convex:

Token value Superellipse Corner
notch superellipse(-infinity) Concave right angle
scoop superellipse(-1) Concave curve
bevel superellipse(0) Diagonal slice
round superellipse(1) Elliptical curve, the default
squircle superellipse(2) The continuous Material 3 Expressive squircle
square superellipse(infinity) Sharp right angle

For anything in between, write the function directly, such as superellipse(1.5) or superellipse(3).

Chameleon's one public styling surface is CSS custom properties whose names begin --ct-. Every color, corner, and type value a component reads resolves through a --ct-* token. You retheme by setting those properties, globally, on a subtree, or on one element, and never by reaching inside a component. This tab lists what the names are and which of them are stable.

The --ct- prefix⁠

--ct- stands for "Chameleon token", and it's the only prefix to use in your own CSS. Custom properties that begin --_ are private to a component's shadow root and aren't API.

Three token tiers⁠

There are three tiers of custom property, and only the first two are public.

Tier Shape Public? Set it to
System --ct-sys-<family>-<role> Yes Retheme a whole family at once, such as every surface, the corner shape, or the type scale
Per-component --ct-<component>-<token> Yes Override one component's token without touching the rest of the theme
Private --_<token> No Never: it's internal to a component's shadow root

System tokens are the design system's vocabulary, such as --ct-sys-color-primary, --ct-sys-corner-radius-large, and --ct-sys-typescale-body-large-size. Per-component tokens narrow the same idea to one component, such as --ct-button-corner-shape and --ct-button-elevated-container-color. A per-component token, where it exists, falls back to a system token. One system change then ripples through every component. A per-component override changes only its component.

Token names⁠

The component comes first, and a variant follows it. A component with variants spells them --ct-<component>-<variant>-<token>, so every token a component owns sorts together. --ct-button-corner-shape sits beside --ct-button-elevated-container-color and --ct-button-filled-container-color. Type --ct-button- in your editor, and it completes the whole button surface.

The element decides, not how the name reads. A plain tooltip and a rich tooltip are separate elements, so their tokens are --ct-plain-tooltip-* and --ct-rich-tooltip-*. A basic dialog and a full-screen dialog are one element, cw-dialog, with two variants, so theirs are --ct-dialog-basic-* and --ct-dialog-full-screen-*. When you're unsure, check whether the tag exists: there's a cw-plain-tooltip, and there's no cw-basic-dialog.

The resolution chain⁠

Each token resolves through nested CSS var() fallbacks. Inside a component's shadow root, a private property holds the chain, and every visual property reads that private property. Here is the chain for the elevated button's container color:

/* Illustrative: emitted inside the button's shadow root */
:host {
  --_container-color: var(
    --ct-button-elevated-container-color,        /* 1. per-component (public) */
    var(--ct-sys-color-surface-container-low,    /* 2. system (public)        */
        #f8f1fa)                                 /* 3. built-in fallback      */
  );

  background-color: var(--_container-color);
}
content_copy

CSS takes the first property in the chain that has a value:

  1. If you set --ct-button-elevated-container-color, it wins, and only elevated buttons change.
  2. Otherwise --ct-sys-color-surface-container-low applies. Your theme sets it, so it follows the seed and the light or dark scheme.
  3. If you set neither, the built-in hex keeps the component rendering.

The same chain makes global, scoped, and per-instance theming work with no special cases.

System token families⁠

Family Prefix Roles
Color --ct-sys-color-* The Material 3 roles plus Chameleon's status roles: primary, on-primary, primary-container, secondary*, tertiary*, error*, information*, success*, warning*, the surface* tiers, outline, outline-variant, inverse-*, the *-fixed roles, scrim, and shadow
Corner radius --ct-sys-corner-radius-* none, extra-small, small, medium, large, large-increased, extra-large, and full
Corner shape --ct-sys-corner-shape The superellipse family: round, squircle, scoop, bevel, and the rest
Typography --ct-sys-typescale-<role>-<size>-* display, headline, title, body, and label, each large, medium, and small, with -size, -weight, -line-height, -tracking, and -font
Elevation --ct-sys-elevation-* level0 to level5
Motion --ct-sys-motion-* Durations and easing curves
State --ct-sys-state-* The state-layer opacity for hover, pressed, and dragged

Only the color family comes from your seed, as the Color tab describes. The others have built-in Material 3 Expressive defaults: set them at any scope to retheme, or leave them unset.

Three scopes of theming⁠

A component always reads through the chain, so the same tokens theme at three scopes. No component needs to opt in.

Global: set system tokens on :root, or adopt a generated theme stylesheet, and every component picks them up:

:root {
  --ct-sys-corner-shape: squircle;
}
content_copy

Scoped: set tokens on a class or container, and only that subtree changes. A system token set here overrides the global one for its descendants:

.dashboard {
  --ct-sys-color-primary: #1b5fa8;
  --ct-sys-corner-shape: round;
}
content_copy

Per instance: set a per-component token on one element, and only that element changes:

#save {
  --ct-button-corner-shape: squircle;
}
content_copy

Global and scoped overrides usually set system tokens, which cascade to every component beneath them. Per-instance overrides usually set per-component tokens, the first link in the chain. All three work together, and the first property in the chain that has a value wins.

What is stable, what is not⁠

The prefix draws the line.

Stable, so depend on these:

  • Every system token name: --ct-sys-color-*, --ct-sys-corner-radius-*, --ct-sys-corner-shape, --ct-sys-typescale-*, --ct-sys-elevation-*, --ct-sys-motion-*, and --ct-sys-state-*.
  • Every per-component token name, --ct-<component>-<token>. The React and Blazor docs list the same --ct-* names, so the token contract is identical for all frameworks.

Not stable, so never depend on these:

  • Any --_* property. It's the private left-hand side of a chain, such as --_container-color, and any release can rename, split, or remove it. Production builds also shorten these names. Set the --ct-* token it falls back to instead.

The rule in one line: if it starts with --ct-, it's API; if it starts with --_, it isn't.