Components & Variants
cva and cx — building reusable components with style variants on top of plain e() functions.
Chroma has no compiler and no component abstraction beyond a plain function invoked through e(Component, props, ...children) (see Architecture). That's enough to reuse a component's structure, but reusing its look across variants — a Button that is sometimes primary, sometimes ghost, sometimes sm, sometimes lg — usually means hand-rolling class-string concatenation inside every component. cva and cx exist to give that concatenation the same declarative treatment Chroma gives everything else.
Both are pure class-name utilities: they know nothing about the DOM, signals, or e(). A "reusable component with variants" in Chroma is still just a function — cva only computes the string that goes into its class prop.
cva
Defines a class resolver for a component's variants: cva({ base, variants, compoundVariants, defaultVariants }). Returns a function (props) => string.
import { e, cva } from "./pkg/chroma.js";
const buttonClass = cva({
base: "btn",
variants: {
variant: { primary: "btn-primary", ghost: "btn-ghost" },
size: { sm: "btn-sm", lg: "btn-lg" },
},
compoundVariants: [
{ variant: "primary", size: "lg", class: "btn-primary-lg" },
],
defaultVariants: { variant: "primary", size: "sm" },
});
function Button(props) {
return e(
"button",
{ class: buttonClass(props), onClick: props.onClick },
props.children,
);
}
e(Button, { variant: "ghost", size: "lg" }, "Cancel"); // class="btn btn-ghost btn-lg"
e(Button, { variant: "primary", size: "lg" }, "OK"); // class="btn btn-primary btn-lg btn-primary-lg"
e(Button, {}, "Save"); // class="btn btn-primary btn-sm" (defaults)Resolution order:
base(optional) —string | string[], always included.- For each group in
variants(optional,{ group: { option: class } }), the option comes fromprops[group], falling back todefaultVariants[group]when the prop is missing. Unknown options resolve to no class for that group. compoundVariants(optional) —[{ ...selectedOptions, class }]; an entry's class is added only when every named group in it matches the resolved selection.props.class/props.className, appended verbatim last — callers can still bolt on one-off classes without fighting the resolver.
Variant options aren't limited to strings. If props[group] is a boolean, it's coerced to the string "true"/"false" before matching — so a variant group can branch on a boolean prop directly:
const badgeClass = cva({
base: "badge",
variants: {
disabled: { true: "opacity-50 pointer-events-none", false: "" },
},
defaultVariants: { disabled: false },
});
badgeClass({ disabled: true }); // "badge opacity-50 pointer-events-none"Because the resolver is a plain function, nothing stops you from calling it inside a reactive attribute to vary the class after mount:
function Badge(props) {
return e("span", { class: () => badgeClass({ ...props, status: status() }) }, props.children);
}cx
Merges class names, skipping falsy values and flattening arrays — the same shape as the common clsx/classnames helper:
import { cx } from "./pkg/chroma.js";
cx("btn", isActive() && "btn-active", ["a", "b"]); // "btn btn-active a b"
cx("card", null, false, "", "card-highlighted"); // "card card-highlighted"Reach for cx for one-off conditional classes that don't need cva's named-variant model — e.g. inside a reactive attribute:
e("div", { class: () => cx("card", isActive() && "card-active") });Building a small design system
Because components stay plain functions, a handful of cva-backed ones compose like any other Chroma component — pass them to e(), nest them, wrap them in Show/For, give them their own hooks:
const badgeClass = cva({
base: "badge",
variants: { tone: { info: "badge-info", danger: "badge-danger" } },
defaultVariants: { tone: "info" },
});
function Badge(props) {
return e("span", { class: badgeClass(props) }, props.children);
}
function StatusList({ items }) {
return e(For, {
each: items,
children: (item) => e(Badge, { tone: item.tone }, item.label),
});
}See API Reference for the full signatures, Control Flow for Show/For, and example/index.html in the repository for a runnable Button built this way.