Composition — the cia decision tree
cia is mixin-first: you author your own selectors and @include the system. This page is the decision tree from design intent to cia code — covering every value you might need to express and where to reach in cia. Read it once; the rest of the docs make sense after.
Written equally for two audiences: humans authoring components and AI agents generating cia code via the MCP server. The decision tree is identical for both.
The decision tree
You need to express a value. Walk this tree:
Need a value in your component →
├─ Is it a color, type, radius, shadow, motion timing?
│ → Use the themed token:
│ m.color() / m.font-size() / m.radius() / m.shadow() / m.duration()
│ (varies per theme — consumers can re-tune)
│
├─ Is it a themeable space (margin, padding, gap)?
│ → m.space(n)
│ (themed spacing scale — consumers can re-tune)
│
├─ Is it an explicit geometric size on the 4px grid?
│ → m.grid(n)
│ (NOT themed — geometric truth, shared with Figma)
│
├─ Is it an off-grid pixel value (rare)?
│ → m.px(value)
│ (raw rem conversion — escape hatch, not the default)
│
└─ Is it a component or pattern?
├─ Does a cia mixin match? → m.btn() / m.modal() / m.card-base() / etc.
│ Customize via parameters: cia.btn(primary, $bg: brand-accent, $r: full)
│ Extend via @content: cia.btn(primary) { letter-spacing: 0.05em; }
│
├─ Does a recipe show the pattern? → follow scss/recipes/<name>.md
│ (dialog, combobox, mobile-nav, bottom-nav, print-to-pdf)
│
└─ Otherwise: compose from cia primitives
(m.flex / m.stack / m.cluster / m.btn-base / m.focus-ring / m.transition)Page-level structure — shells, named grid areas, the mobile toolkit — has its own decision path: m.page-layout() / m.layout(), covered in the mobile playbook.
Branch 1 — Themed tokens (color, type, radius, shadow, motion)
These are design choices. They vary per theme. Use the typed token function; the value is whatever the active theme declares.
// theme provides --action-primary-default; m.color() returns var(--action-primary-default) .my-cta { background: m.color(action-primary-default); color: m.color(text-inverse); font-size: m.font-size(3); border-radius: m.radius(md); box-shadow: m.shadow(2); transition-duration: m.duration(normal); }
Branch 2 — Themeable spacing
Margin, padding, gap. cia ships a numbered space scale (1–9) with t-shirt aliases (xs through 4xl). The scale itself IS themeable — consumers can re-tune the spacing per theme.
.feed { padding: m.space(4) m.space(6); gap: m.space(3); }
Branch 3 — Geometric grid (NOT themed)
Explicit sizes on cia's 4px coordinate system. Use for icon widths, control heights, fixed dimensions that must align with the design grid. NOT themed — consumers don't tune the 4px grid; it's geometric truth.
Why distinct from m.space()? m.space() is themeable (varies per theme); m.grid() is geometric (fixed across all themes). Same math, different semantics — use m.space() for margins and gaps, m.grid() for icon widths and control heights.
.icon-sm { width: m.grid(4); } // 16px .control-md { height: m.grid(10); } // 40px .avatar-xl { inline-size: m.grid(20); }// 80px
Branch 4 — Off-grid pixel values (rare)
For the rare case where a value doesn't fit any cia scale. Returns rem so user zoom still works. Prefer m.grid() / m.space() / typed tokens when they fit — m.px() is the escape hatch, not the default.
.hero { margin-block-start: m.px(33); }// 2.0625rem .badge { margin-inline-end: m.px(17); }// 1.0625rem
Branch 5a — Component mixin + parameter-power
cia ships component mixins for the patterns most consumers need: m.btn(), m.modal(), m.card-base(), m.input-base(), etc. Every cia mixin accepts overrides — this is parameter-power. If the default doesn't fit, override the parameter.
// the default .checkout-cta { @include cia.btn(primary); } // custom background (hover + active derive automatically from $bg) .brand-cta { @include cia.btn(primary, $bg: brand-accent); } // custom radius + padding + size .pill-cta { @include cia.btn(primary, $px: 6, $r: full, $font-size: 3); } // AND extend via @content for anything the params don't cover .tracked-cta { @include cia.btn(primary) { letter-spacing: 0.05em; text-transform: uppercase; } }
The MCP server's get_mixin tool returns the full parameter list for any cia mixin. AI agents: when you need a component, fetch the mixin's signature first and override parameters rather than reaching for a literal value.
Branch 5b — Recipes for higher-level patterns
When a single mixin isn't enough, cia ships recipes — full framework-agnostic patterns: dialog, combobox, print-to-PDF, mobile-nav, bottom-nav. Each recipe is a markdown file at scss/recipes/<name>.md with structure, styling, a11y, and framework examples.
Recipes assemble cia primitives — they don't replace them. Read the recipe; copy the structure; let your selector names own the markup.
Branch 5c — Composing from primitives
When no mixin and no recipe matches your component, compose from cia primitives directly. The system gives you the building blocks (m.flex, m.stack, m.cluster, m.btn-base, m.focus-ring, m.transition, the typed token functions) — assemble them into your bespoke component.
// A bespoke pricing-tier card — no single cia mixin matches; compose .pricing-tier { @include m.card-base($shadow: 2); @include m.stack($gap: 4); padding: m.space(6); border: 1px solid m.color(border-default); &[data-featured="true"] { border-color: m.color(action-primary-default); box-shadow: m.shadow(3); } .price { font-size: m.font-size(6); font-weight: m.font-weight(bold); } .cta { @include m.btn(primary); } }
The system disappears at this point — you're writing your own component, with cia's tokens and primitives feeding it. The result is bespoke but coherent with the design system.
A bespoke component — three ways
Build the same multi-step pricing-tier card three ways. Compare and see why cia's composition story is the path of least friction.
The Tailwind way (utility soup)
<div class="bg-white dark:bg-slate-900 rounded-lg shadow-md p-6
border border-slate-200 dark:border-slate-700 flex flex-col gap-4
data-[featured=true]:border-blue-600 data-[featured=true]:shadow-lg
data-[featured=true]:dark:border-blue-400">
<h3 class="text-base font-medium text-slate-700 dark:text-slate-200">
Pro
</h3>
<div class="text-5xl font-bold text-slate-900 dark:text-white">
$24
</div>
<button class="bg-blue-600 hover:bg-blue-700 active:bg-blue-800
dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-300
text-white font-medium px-4 py-2 rounded-md transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2">
Get Pro
</button>
</div>Forty-plus utility classes scattered across the markup. Color values repeated in light + dark variants. Hover / active / focus states spelled out per element. Changing the brand color = find-and-replace across the file.
The Bootstrap way (override fight)
<div class="card p-6 my-pricing-tier">
<h5 class="card-title">Pro</h5>
<div class="display-4">$24</div>
<button class="btn btn-primary my-cta">Get Pro</button>
</div>
<style>
/* override Bootstrap's defaults — fighting specificity */
.my-pricing-tier { gap: 1rem !important; display: flex !important;
flex-direction: column !important; }
.my-pricing-tier .card-title { font-size: 1rem !important; }
.my-pricing-tier[data-featured="true"] {
border-color: var(--bs-primary) !important;
}
.my-pricing-tier .my-cta { padding: 0.5rem 1rem !important; }
</style>Bootstrap classes for structure, then !important wars for every customization. Dark mode? You're editing CSS vars or adding another stylesheet. Change the brand? Edit _variables.scss and rebuild.
The cia way (composition)
// Markup uses YOUR class names; cia mixins do the work .pricing-tier { @include m.card-base($shadow: 2); @include m.stack($gap: 4); padding: m.space(6); border: 1px solid m.color(border-default); &[data-featured="true"] { border-color: m.color(action-primary-default); box-shadow: m.shadow(3); } .price { font-size: m.font-size(6); font-weight: m.font-weight(bold); } .cta { @include m.btn(primary); } }
Fourteen lines of SCSS. Dark mode? Already handled — the tokens emit light-dark() per the active theme. Change the brand? Edit one theme file. Different size buttons across the app? Pass $px / $r to btn() per use. Same vocabulary across every component in your app.
For AI agents — the contract
If you're generating cia code (via MCP, Figma → Code, or any agent workflow), the contract is:
- Resolve the value via the decision tree above. Never write a literal rem / px / hex that doesn't come from a cia function or token.
- For size values from a design tool (e.g. Figma gives 24px): call the MCP
resolve_sizetool → returns step6→ emitm.grid(6). - For component patterns: call MCP
get_mixinwith the component name → receive the full parameter list → use parameter-power. Reach for@contentonly when no parameter matches. - For higher-level patterns: call MCP
get_recipe→ receive the recipe's structure + a11y checklist + framework examples → adapt to the consumer's framework. - When nothing matches: compose from cia primitives per the "Composing from primitives" section above.
This contract is what makes cia AI-comprehensible: every value has a deterministic source, every component has an overridable mixin or recipe, and every fallback is documented.