Styling
Style Chroma apps with @chroma/style — design tokens, utility classes, and cva presets that share the engine's class resolver.
Chroma components are plain functions that return DOM with class strings. Without a shared layer, every app reinvents colors, spacing, and button variants. @chroma/style (chroma-style) is the first-party plugin that gives you a small, consistent design system: CSS custom properties, atomic utility classes, and ready-made cva presets that use the same cva and cx helpers exported from the WASM engine.
In plain words: Chroma-Style injects two stylesheets (tokens.css, utilities.css), then optional JavaScript presets build class strings through the engine's cva. You keep writing e("button", { class: ... }) — the plugin only supplies the vocabulary.
It is intentionally small (not Tailwind). For a large design system, add your own CSS files or bring an external framework.
What the plugin provides
| Layer | File / module | Role |
|---|---|---|
| Design tokens | tokens.css | Colors, radii, shadows, type scale as --background, --primary, --radius-md, … plus brand --cs-* hex values |
| Utilities | utilities.css | A fixed subset of classes (bg-primary, text-sm, flex, gap-2, …) mapped to those tokens |
| Presets | variants.js → createVariants(cva) | button, badge, card, input variant configs aligned with the utilities |
| Helpers | index.js | injectStyles, bootstrapStyle, createTw, utilityClasses catalog |
Package reference: Chroma-Style.
Quick start
Templates from chroma new already list chroma-style in chroma.json and vendor the plugin under plugins/chroma-style/.
// src/main.chr — dynamic / standard templates
import { render, cva, cx } from "../pkg/chroma.js";
import { loadPlugins } from "../plugins/loader.js";
import { App } from "./App.chr";
const plugins = await loadPlugins();
if (plugins.style) {
globalThis.__chromaStyle = plugins.style.bootstrapStyle({ cva, cx });
}
render(App, document.getElementById("app"));In any component:
// src/components/PrimaryAction.chr
import { e } from "../../pkg/chroma.js";
const { presets, tw } = globalThis.__chromaStyle;
export function PrimaryAction(props) {
return e(
"button",
{
class: presets.button({ variant: "primary", size: "md", class: props.class }),
onClick: props.onClick,
disabled: props.disabled,
},
props.children,
);
}The standard template also calls bootstrapStyle in main.chr but its demo UI still uses inline CSS in index.html for the file explorer — a valid pattern when you have not wired presets yet. The plugin styles are still injected by the loader's styles hook; you can migrate controls to presets.* incrementally.
Styling pipeline
Understanding when CSS loads and who builds class strings avoids "classes exist in JS but never hit the DOM" bugs.
loadPlugins()readschroma.plugin.jsonand runs thestyleshook (injectStyles) before your app renders.bootstrapStyle({ cva, cx })is your responsibility inmain.chr(the manifest does not register abootstraphook). It returnstw,presets, and metadata. Withoutcva,presetsisnullbut CSS still loads.- Components only assign strings to
class; the engine does not interpret Tailwind-like syntax at runtime.
Details of the plugin contract: Chroma-Style as a plugin.
Portable (Web) vs Executable (Desktop)
Styling behaves the same in both runtime modes: CSS runs in the browser (or Desktop iframe) with standard DOM APIs. There is no separate native styling path.
| Topic | Portable (Web) | Executable (Chroma Desktop) |
|---|---|---|
| Injection | injectStyles() adds <link> tags in the page | Same inside the iframe |
| Stylesheet URLs | Resolved relative to plugins/chroma-style/ via import.meta.url | Same; dev server or library server serves plugins/ |
| Build output | chroma build copies plugins/ into dist/ | Imported zip includes the same plugins/chroma-style/*.css |
| Theming | data-theme on <html> or system prefers-color-scheme | Same |
| Extra app CSS | Use <link rel="stylesheet"> in index.html | Same; dev server serves .css as text/css |
Practical differences are about hosting, not the plugin API:
- Origins differ between a browser tab (
chroma dev --no-desktop) and Desktop develop mode, so any theme preference stored inlocalStorageis not shared across hosts. - Avoid FOUC on slow networks by linking plugin CSS (and your overrides) in
index.htmlbefore the module entry, not only relying on lateinjectStyles(). Duplicating the token variables in your own stylesheet (for examplesrc/styles/theme.css) is a common way to avoid the flash. - Do not
import "./foo.css"from.chrmodules — the Chroma dev server does not treat CSS as an ES module. Link stylesheets from HTML or inject withinjectStyles().
Full host comparison: Runtime modes · Chroma-Style — Runtime hosts.
Set up manually
-
Add
chroma-styletopluginsinchroma.json(allchroma newtemplates include it).{ "name": "my-app", "mode": "dynamic", "plugins": ["chroma-fs", "chroma-router", "chroma-style"] } -
Run
chroma install/chroma newsoplugins/chroma-style/exists. -
Call
bootstrapStyle({ cva, cx })once beforerender(), as in the quick start.
Without the generic loader, import from the vendored path:
import { injectStyles, bootstrapStyle } from "./plugins/chroma-style/index.js";
injectStyles();
const { tw, presets } = bootstrapStyle({ cva, cx });Or link CSS from index.html:
<link rel="stylesheet" href="./plugins/chroma-style/tokens.css" />
<link rel="stylesheet" href="./plugins/chroma-style/utilities.css" />How it relates to cva and cx
@chroma/style does not reimplement cva or cx. Presets are plain configs passed to the engine's cva, so props.class is appended last — the same resolution order as your own components. See Components — cva.
The engine demo in Chroma/example/index.html shows both patterns side by side: a local buttonClass cva for demo-specific classes, and presets.card / presets.button from the plugin for the style showcase:
// Pattern from Chroma/example — presets + tw in one section
e(
"section",
{ class: presets.card({ shadow: "md", padding: "md" }) },
e("p", { class: tw("text-muted-foreground", "text-sm", "mb-4") }, "Utility classes + cva presets"),
e("button", { class: presets.button({ variant: "outline", size: "sm" }) }, "Outline"),
e("input", { class: presets.input(), placeholder: "Input preset", type: "text" }),
);Design tokens
Utilities and presets read semantic variables (--background, --primary, …). Brand colors are stored as --cs-* hex values; semantic aliases keep existing class names stable when you retheme.
Override tokens after the plugin stylesheet loads:
/* src/styles/theme.css — load after tokens.css */
:root {
--cs-primary: #2563eb;
--primary: var(--cs-primary);
--radius-md: 0.5rem;
}| Group | Variables |
|---|---|
| Colors | --background, --foreground, --card, --primary, --secondary, --muted, --accent, --destructive, each with a -foreground pair, plus --border, --input, --ring |
| Brand (optional) | --cs-background, --cs-primary, … — see Tokens |
| Radius | --radius-none through --radius-full |
| Shadow | --shadow-sm through --shadow-xl |
| Typography | --font-sans, --text-xs through --text-2xl with matching -leading variables |
| Motion (tokens only) | --animate-duration, --animate-ease, --animate-stagger — used by app CSS or animation libraries |
Light and dark theme
- Set
data-theme="dark"ordata-theme="light"on<html>to force a theme. - With no
data-theme, the dark palette applies when the system prefers dark (prefers-color-scheme).
// src/lib/theme.chr
export function toggleTheme() {
const root = document.documentElement;
root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
}Utility classes
Utilities are explicit CSS rules, not a JIT compiler. Only classes defined in utilities.css work. The full catalog is exported as utilityClasses for tooling:
import { utilityClasses } from "../plugins/chroma-style/index.js";
utilityClasses.background; // e.g. "bg-background"
utilityClasses.presets; // ["button", "badge", "card", "input"]Not included: responsive prefixes (sm:, md:), grid systems (grid-cols-*), or container. Use plain CSS @media blocks, reactive layout branches (Show + a breakpoint helper), or domain classes.
Component shells and animation hooks
For querySelector targets (for example Anime.js), utilities also ship stable component classes:
| Selector | Use |
|---|---|
.cs-card | Card shell using token colors |
.cs-btn, .cs-btn-primary, .cs-btn-outline, … | Button shells |
.cs-btn-sm, .cs-btn-md, .cs-btn-lg | Size shells |
[data-animate="fade-up"], fade-in, scale-in | Initial state before JS animation (respects prefers-reduced-motion) |
.is-disabled | Disabled look without :disabled in the selector |
List reference: Utilities.
Presets
createVariants(cva) returns { button, badge, card, input }. You normally read them from bootstrapStyle({ cva, cx }).presets.
| Preset | Variant group | Options | Default |
|---|---|---|---|
button | variant | primary, secondary, outline, ghost, destructive | primary |
button | size | sm, md, lg | md |
badge | variant | default, secondary, outline, destructive | default |
card | padding | none, sm, md | md |
card | shadow | none, sm, md, lg | sm |
input | invalid | true (adds border-destructive) | none |
Preset source (real variants.js in the repo):
const button = cva({
base: "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-ring disabled:opacity-50 disabled:pointer-events-none border",
variants: {
variant: {
primary: "bg-primary text-primary-foreground border-transparent",
secondary: "bg-secondary text-secondary-foreground border-transparent",
outline: "bg-transparent text-foreground border-input",
ghost: "bg-transparent text-foreground border-transparent",
destructive: "bg-destructive text-destructive-foreground border-transparent",
},
size: { sm: "px-3 py-1 text-xs", md: "px-4 py-2 text-sm", lg: "px-4 py-2 text-base" },
},
defaultVariants: { variant: "primary", size: "md" },
});More examples: Presets.
Wrap a preset in a component
// src/SaveButton.chr
import { e } from "../pkg/chroma.js";
const { presets } = globalThis.__chromaStyle;
export function SaveButton(props) {
return e(
"button",
{ class: presets.button(props), onClick: props.onClick, disabled: props.disabled },
props.children,
);
}
// Callers add layout utilities through class, which cva resolves last.
// e(SaveButton, { variant: "outline", size: "sm", class: "mt-4" }, "Cancel")Merge presets with one-off classes
// src/Panel.chr
import { e, cx } from "../pkg/chroma.js";
const { presets, tw } = globalThis.__chromaStyle;
export function Panel(props) {
return e(
"div",
{ class: cx(presets.card({ padding: "md" }), "mt-4 shadow-lg") },
e("p", { class: tw("text-muted-foreground", "text-sm", props.active && "text-primary") }, props.text),
);
}Reactive classes
Presets return a string at call time. For live updates, call the resolver inside a reactive attribute:
// src/components/StatusBadge.chr
import { e } from "../../pkg/chroma.js";
const { presets } = globalThis.__chromaStyle;
export function StatusBadge(props) {
return e(
"span",
{ class: () => presets.badge({ variant: props.variant() }) },
props.children,
);
}Your own cva on top of the utilities
// src/Alert.chr
import { e, cva } from "../pkg/chroma.js";
const alertClass = cva({
base: "rounded-md border px-4 py-2 text-sm",
variants: {
tone: {
info: "bg-secondary text-secondary-foreground border-transparent",
error: "bg-destructive text-destructive-foreground border-transparent",
},
},
defaultVariants: { tone: "info" },
});
export function Alert(props) {
return e("div", { class: alertClass({ tone: props.tone, class: "mt-2" }) }, props.children);
}Conventions
| Convention | Recommendation |
|---|---|
| Global handle | globalThis.__chromaStyle after bootstrap — matches the templates |
| Preset wrappers | Thin .chr components that call presets.* keep variant names consistent |
| Overrides | Prefer token overrides in CSS; use class on preset calls for layout (mt-4, w-full) |
| Static template | index.js can bootstrap the same way; entry is not .chr but the plugin API is identical |
| Patching | Plugin is vendored under plugins/chroma-style/ — safe to fork for app-specific tokens |
| Validation | Compare class names against utilityClasses instead of guessing Tailwind names |
API
All exports come from plugins/chroma-style/index.js.
| Export | Description |
|---|---|
bootstrapStyle({ cva, cx }) | Injects CSS and returns { tw, presets, utilityClasses, injectStyles, createVariants }. presets is null if cva was not passed. |
injectStyles() | Adds <link> tags for tokens.css and utilities.css. Idempotent. |
createVariants(cva) | Builds the presets. createPresets is a deprecated alias. |
createTw(cxFn?) | Class merger using cx when provided. |
utilityClasses | Class names grouped by category. |
plugin | Default export object for generic loaders (hooks.bootstrap / hooks.styles). |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Classes appear in DevTools but have no effect | Stylesheets not loaded | Ensure loadPlugins() ran, or add <link> tags; call injectStyles() |
presets is undefined | Bootstrap without cva | Pass engine cva from pkg/chroma.js |
presets.button is not a function | Bootstrap never ran | Call bootstrapStyle before importing components that use it at module top level |
| Unknown utility has no styles | Class not in utilities.css | Add app CSS or extend utilities.css in the vendored plugin |
| Flash of unstyled content | CSS loads after first paint | Link tokens.css / utilities.css in index.html head |
| Theme stuck wrong | data-theme vs system | Set document.documentElement.dataset.theme explicitly |
Next
- Chroma-Style package docs
- Runtime modes — Portable vs Executable hosts
- Components —
cva/cx - Plugins — loader and manifests
- Examples
@chroma/style is proprietary software. See Chroma-Style and Licensing.