Variants & Styling
How Kiso's variant system works — class_variants, color × variant axes, and style overrides.
If you know cva, you know class_variants
React’s cva (class-variance-authority) and Vue’s Tailwind Variants let you define component styles as variant maps. Kiso uses class_variants — the same concept in Ruby:
cva (JS)
const badge = cva("inline-flex items-center", {
variants: {
size: {
sm: "px-2.5 text-xs",
md: "px-3 text-xs",
}
},
defaultVariants: { size: "md" }
})
class_variants (Ruby)
Badge = ClassVariants.build(
base: "inline-flex items-center",
variants: {
size: {
sm: "px-2.5 text-xs",
md: "px-3 text-xs",
}
},
defaults: { size: :md }
)
Theme modules live in lib/kiso/themes/ and are called from partials:
class: Kiso::Themes::Badge.render(color: color, variant: variant, size: size, class: css_classes)
.render() resolves the variant values, concatenates the matching classes,
and merges any overrides through tailwind_merge (so conflicting classes are
resolved automatically, not duplicated).
The two-axis system
Colored components in Kiso use two variant axes, borrowed from Nuxt UI:
color:— what color:primary,secondary,success,info,warning,error,neutralvariant:— how it’s rendered:solid,outline,soft,subtle
<%= kui(:badge, color: :success, variant: :solid) { "Shipped" } %>
<%= kui(:badge, color: :error, variant: :outline) { "Failed" } %>
<%= kui(:badge, color: :info, variant: :soft) { "Pending" } %>
Every color × variant combination is defined as a compound variant in the
theme module. The formulas are identical across all colored components — Badge,
Alert, and Button all produce the same styles for :success + :solid.
Semantic tokens, not raw colors
Components never use Tailwind palette shades like bg-blue-600 or
text-red-500. Instead, they use semantic tokens:
| Token | Purpose |
|---|---|
bg-primary |
Brand primary background |
text-primary-foreground |
Accessible text on primary background |
bg-muted |
Subtle background |
text-muted-foreground |
Secondary text |
bg-elevated |
Raised surface |
border |
Default border |
These tokens map to CSS custom properties that flip automatically in dark mode. See the Design System page for the full token table.
Overriding styles with css_classes:
Every component accepts css_classes: — a string of Tailwind classes that
gets merged into the component’s computed classes:
<%= kui(:badge, css_classes: "rounded-none text-base") { "Custom" } %>
This is similar to React’s className or Vue’s :class binding, but with a
key difference: conflicts are resolved automatically via tailwind_merge.
If the badge defaults to rounded-full and you pass rounded-none, the
output will contain only rounded-none — no duplication, no specificity
battle.
<% # Override padding and add a shadow %>
<%= kui(:card, css_classes: "p-8 shadow-lg") do %>
Spacious card
<% end %>
Overriding inner elements with ui:
css_classes: only reaches the root element. To target inner sub-parts, use
ui: with a hash of slot names to class strings:
<%= kui(:card, ui: { header: "p-8 bg-muted", title: "text-xl" }) do %>
<%= kui(:card, :header) do %>
<%= kui(:card, :title) { "Dashboard" } %>
<% end %>
<% end %>
Self-rendering components like Alert and Slider apply ui: to their internal
structure automatically:
<%= kui(:slider, ui: { track: "bg-muted", thumb: "bg-primary" }) %>
See Customizing Components for the full override layer system.