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
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];
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,
});
| 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:
neutralandmonochrome: restrained, for editorial layoutstonal-spot: the classic Material 3 lookcontentandfidelity: stay close to the exact seed hueexpressiveandvibrant: energetic, for hero sections and launchesfruit-saladandrainbow: several hues, best used sparinglychord: 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] },
);
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);
}
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);
}
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;
}
| 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);
}
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);
}
CSS takes the first property in the chain that has a value:
- If you set
--ct-button-elevated-container-color, it wins, and only elevated buttons change. - Otherwise
--ct-sys-color-surface-container-lowapplies. Your theme sets it, so it follows the seed and the light or dark scheme. - 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;
}
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;
}
Per instance: set a per-component token on one element, and only that element changes:
#save {
--ct-button-corner-shape: squircle;
}
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.