Targ Apps Docs
Chroma

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

LayerFile / moduleRole
Design tokenstokens.cssColors, radii, shadows, type scale as --background, --primary, --radius-md, … plus brand --cs-* hex values
Utilitiesutilities.cssA fixed subset of classes (bg-primary, text-sm, flex, gap-2, …) mapped to those tokens
Presetsvariants.js → createVariants(cva)button, badge, card, input variant configs aligned with the utilities
Helpersindex.jsinjectStyles, 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.

  1. loadPlugins() reads chroma.plugin.json and runs the styles hook (injectStyles) before your app renders.
  2. bootstrapStyle({ cva, cx }) is your responsibility in main.chr (the manifest does not register a bootstrap hook). It returns tw, presets, and metadata. Without cva, presets is null but CSS still loads.
  3. 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.

TopicPortable (Web)Executable (Chroma Desktop)
InjectioninjectStyles() adds <link> tags in the pageSame inside the iframe
Stylesheet URLsResolved relative to plugins/chroma-style/ via import.meta.urlSame; dev server or library server serves plugins/
Build outputchroma build copies plugins/ into dist/Imported zip includes the same plugins/chroma-style/*.css
Themingdata-theme on <html> or system prefers-color-schemeSame
Extra app CSSUse <link rel="stylesheet"> in index.htmlSame; 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 in localStorage is not shared across hosts.
  • Avoid FOUC on slow networks by linking plugin CSS (and your overrides) in index.html before the module entry, not only relying on late injectStyles(). Duplicating the token variables in your own stylesheet (for example src/styles/theme.css) is a common way to avoid the flash.
  • Do not import "./foo.css" from .chr modules — the Chroma dev server does not treat CSS as an ES module. Link stylesheets from HTML or inject with injectStyles().

Full host comparison: Runtime modes · Chroma-Style — Runtime hosts.

Set up manually

  1. Add chroma-style to plugins in chroma.json (all chroma new templates include it).

    {
      "name": "my-app",
      "mode": "dynamic",
      "plugins": ["chroma-fs", "chroma-router", "chroma-style"]
    }
  2. Run chroma install / chroma new so plugins/chroma-style/ exists.

  3. Call bootstrapStyle({ cva, cx }) once before render(), 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;
}
GroupVariables
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" or data-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:

SelectorUse
.cs-cardCard shell using token colors
.cs-btn, .cs-btn-primary, .cs-btn-outline, …Button shells
.cs-btn-sm, .cs-btn-md, .cs-btn-lgSize shells
[data-animate="fade-up"], fade-in, scale-inInitial state before JS animation (respects prefers-reduced-motion)
.is-disabledDisabled 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.

PresetVariant groupOptionsDefault
buttonvariantprimary, secondary, outline, ghost, destructiveprimary
buttonsizesm, md, lgmd
badgevariantdefault, secondary, outline, destructivedefault
cardpaddingnone, sm, mdmd
cardshadownone, sm, md, lgsm
inputinvalidtrue (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

ConventionRecommendation
Global handleglobalThis.__chromaStyle after bootstrap — matches the templates
Preset wrappersThin .chr components that call presets.* keep variant names consistent
OverridesPrefer token overrides in CSS; use class on preset calls for layout (mt-4, w-full)
Static templateindex.js can bootstrap the same way; entry is not .chr but the plugin API is identical
PatchingPlugin is vendored under plugins/chroma-style/ — safe to fork for app-specific tokens
ValidationCompare class names against utilityClasses instead of guessing Tailwind names

API

All exports come from plugins/chroma-style/index.js.

ExportDescription
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.
utilityClassesClass names grouped by category.
pluginDefault export object for generic loaders (hooks.bootstrap / hooks.styles).

Troubleshooting

SymptomLikely causeFix
Classes appear in DevTools but have no effectStylesheets not loadedEnsure loadPlugins() ran, or add <link> tags; call injectStyles()
presets is undefinedBootstrap without cvaPass engine cva from pkg/chroma.js
presets.button is not a functionBootstrap never ranCall bootstrapStyle before importing components that use it at module top level
Unknown utility has no stylesClass not in utilities.cssAdd app CSS or extend utilities.css in the vendored plugin
Flash of unstyled contentCSS loads after first paintLink tokens.css / utilities.css in index.html head
Theme stuck wrongdata-theme vs systemSet document.documentElement.dataset.theme explicitly

Next

@chroma/style is proprietary software. See Chroma-Style and Licensing.

On this page