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, neutral
  • variant: — 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.