Building Your Own Components

Create custom components using the same patterns Kiso uses internally — themes, partials, sub-parts, and domain wrappers.

Kiso gives you 69 components out of the box. But every app has UI that’s specific to its domain — a MemberBadge that takes a user and renders their role, a PricingCard with header/footer slots, a ProjectStatus indicator tied to your business logic.

You have two options for building these:

  1. Wrap a Kiso component — thin partials that bake in your defaults and domain logic while delegating rendering to Kiso
  2. Build a standalone component — full theme module + partial using the appui() helper, with the same variant system Kiso uses internally

Start with wrapping. It’s simpler, gets you running in minutes, and covers most cases. Reach for standalone components when you need your own variant axes, sub-parts, or structural control beyond what a wrapper provides.


Wrapping Kiso components

The simplest way to build domain components. Create a partial in your app that calls kui() with your defaults baked in.

Basic wrapper

<% # app/views/shared/_primary_button.html.erb %>
<% # locals: (css_classes: "", **rest) %>
<%= kui(:button, variant: :solid, color: :primary, css_classes: css_classes, **rest) do %>
  <%= yield %>
<% end %>
<%= render "shared/primary_button" do %>
  Get started
<% end %>

This is the same pattern as wrapping a shadcn component in a React component to bake in project defaults.

Domain wrapper with business logic

A wrapper can accept your domain objects and translate them into component props:

<% # app/views/members/_member_badge.html.erb %>
<% # locals: (member:, size: :md, css_classes: "", **rest) %>
<%
  color = case member.role
          when "admin"  then :warning
          when "owner"  then :error
          when "member" then :primary
          else :neutral
          end
%%>
<%= kui(:badge, color: color, variant: :soft, size: size, css_classes: css_classes, **rest) do %>
  <%= member.role.titleize %>
<% end %>
<% # Usage — your domain object goes in, styled badge comes out %>
<%= render "members/member_badge", member: @user %>

The caller doesn’t think about colors or variants. They pass a member, and the right badge appears.

Wrapper with composed sub-parts

You can compose multiple Kiso components inside a wrapper:

<% # app/views/projects/_project_card.html.erb %>
<% # locals: (project:, css_classes: "", **rest) %>
<%= kui(:card, css_classes: css_classes, **rest) do %>
  <%= kui(:card, :header) do %>
    <%= kui(:card, :title) { project.name } %>
    <%= kui(:card, :description) { project.tagline } %>
  <% end %>
  <%= kui(:card, :content) do %>
    <div class="flex items-center gap-2">
      <%= kui(:badge, color: project.status_color, variant: :soft, size: :sm) do %>
        <%= project.status.titleize %>
      <% end %>
      <span class="text-sm text-muted-foreground">
        Updated <%= time_ago_in_words(project.updated_at) %> ago
      </span>
    </div>
  <% end %>
  <%= kui(:card, :footer) do %>
    <%= kui(:button, href: project_path(project), variant: :outline, size: :sm) do %>
      View project
    <% end %>
  <% end %>
<% end %>
<% # Usage %>
<% @projects.each do |project| %>
  <%= render "projects/project_card", project: project %>
<% end %>

Forwarding css_classes: and **rest

Always accept css_classes: and **rest in your wrapper and forward them to the underlying Kiso component. This lets callers customize your wrapper the same way they’d customize any Kiso component:

<% # The caller can still override styles %>
<%= render "shared/primary_button", css_classes: "w-full" do %>
  Submit
<% end %>

If your wrapper composes multiple components and you want to let callers target inner elements, forward ui: too:

<% # app/views/shared/_feature_card.html.erb %>
<% # locals: (title:, description: nil, icon: nil, ui: {}, css_classes: "", **rest) %>
<%= kui(:card, variant: :outline, ui: ui, css_classes: css_classes, **rest) do %>
  <%= kui(:card, :header) do %>
    <% if icon %>
      <%= kiso_icon(icon, class: "size-5 text-primary") %>
    <% end %>
    <%= kui(:card, :title) { title } %>
    <% if description %>
      <%= kui(:card, :description) { description } %>
    <% end %>
  <% end %>
  <%= kui(:card, :content) { yield } if block_given? %>
<% end %>
<%= render "shared/feature_card",
    title: "Analytics",
    description: "Track everything.",
    icon: "bar-chart",
    ui: { header: "pb-2", title: "text-xl" } do %>
  <p>More details here...</p>
<% end %>

Standalone components with appui()

When you need your own variant system — custom sizes, states, color axes — or want sub-part composition, build a standalone component using the same system Kiso uses internally.

Generate the scaffold

bin/rails generate kiso:component status_indicator

This creates:

app/themes/default/status_indicator.rb        # Theme module
app/views/components/_status_indicator.html.erb  # Partial

With sub-parts:

bin/rails generate kiso:component pricing_card --sub-parts header body footer
app/themes/default/pricing_card.rb
app/themes/default/pricing_card_header.rb
app/themes/default/pricing_card_body.rb
app/themes/default/pricing_card_footer.rb
app/views/components/_pricing_card.html.erb
app/views/components/pricing_card/_header.html.erb
app/views/components/pricing_card/_body.html.erb
app/views/components/pricing_card/_footer.html.erb

Define your theme

The generator creates an empty theme. Fill it in with your variant definitions:

# app/themes/default/status_indicator.rb
AppThemes::StatusIndicator = ClassVariants.build(
  base: "inline-flex items-center gap-2 text-sm font-medium",
  variants: {
    status: {
      active:   "text-success",
      inactive: "text-muted-foreground",
      pending:  "text-warning",
      error:    "text-error"
    },
    size: {
      sm: "text-xs gap-1.5",
      md: "text-sm gap-2",
      lg: "text-base gap-2.5"
    }
  },
  defaults: { status: :active, size: :md }
)

This is the same ClassVariants.build that every Kiso component uses. Same API, same tailwind_merge deduplication, same override system.

Build your partial

The generated partial gives you the correct skeleton:

<% # app/views/components/_status_indicator.html.erb %>
<% # locals: (status: :active, size: :md, css_classes: "", **component_options) %>
<%= content_tag :span,
    class: AppThemes::StatusIndicator.render(status: status, size: size, class: css_classes),
    data: kiso_prepare_options(component_options, slot: "status-indicator"),
    **component_options do %>
  <span class="relative flex size-2">
    <% if status == :active %>
      <span class="absolute inline-flex size-full animate-ping rounded-full bg-current opacity-75"></span>
    <% end %>
    <span class="relative inline-flex size-2 rounded-full bg-current"></span>
  </span>
  <%= yield %>
<% end %>

Use it

<%= appui(:status_indicator, status: :active) { "Online" } %>
<%= appui(:status_indicator, status: :pending, size: :sm) { "Processing" } %>
<%= appui(:status_indicator, status: :error) { "Connection lost" } %>

Understanding the partial pattern

Every component partial — Kiso’s and yours — follows the same structure:

<% # 1. Declare accepted locals with defaults %>
<% # locals: (variant: :default, css_classes: "", **component_options) %>

<% # 2. Render the root element with computed classes %>
<%= content_tag :div,
    class: AppThemes::MyComponent.render(variant: variant, class: css_classes),
    data: kiso_prepare_options(component_options, slot: "my-component"),
    **component_options do %>

  <% # 3. Yield for caller content %>
  <%= yield %>
<% end %>

Here’s what each piece does:

Piece Purpose
locals: Declares props with defaults. Rails raises on unknown props.
css_classes: "" Single override point for the root element. Always include this.
**component_options Catch-all for HTML attributes (id:, data:, aria:).
AppThemes::MyComponent.render(...) Resolves variants to a class string. class: css_classes merges caller overrides via tailwind_merge.
kiso_prepare_options(component_options, slot: "...") Sets data-slot for identity and merges data: attributes.
**component_options (on content_tag) Forwards remaining HTML attributes to the element.
yield Renders the caller’s block content.

Sub-parts

Sub-part partials follow the same pattern, just nested under the component directory:

<% # app/views/components/pricing_card/_header.html.erb %>
<% # locals: (css_classes: "", **component_options) %>
<%= content_tag :div,
    class: AppThemes::PricingCardHeader.render(class: css_classes),
    data: kiso_prepare_options(component_options, slot: "pricing-card-header"),
    **component_options do %>
  <%= yield %>
<% end %>
<% # Usage with sub-parts %>
<%= appui(:pricing_card) do %>
  <%= appui(:pricing_card, :header) do %>
    <h3 class="font-semibold">Pro Plan</h3>
    <p class="text-muted-foreground">For growing teams</p>
  <% end %>
  <%= appui(:pricing_card, :body) do %>
    <p class="text-4xl font-bold">$49<span class="text-lg text-muted-foreground">/mo</span></p>
  <% end %>
  <%= appui(:pricing_card, :footer) do %>
    <%= kui(:button, variant: :solid, css_classes: "w-full") { "Get started" } %>
  <% end %>
<% end %>

Notice how appui() and kui() mix freely. Use Kiso components inside your app components and vice versa.

Per-slot overrides with ui:

Your standalone components automatically support ui: overrides through the appui() helper — the same system Kiso components use. Callers can target sub-part slots by name:

<%= appui(:pricing_card, ui: { header: "bg-muted p-6", footer: "border-t p-4" }) do %>
  <%= appui(:pricing_card, :header) { "Custom header styling" } %>
  <%= appui(:pricing_card, :footer) { "Custom footer styling" } %>
<% end %>

Mixing kui() and appui() — who does what?

Helper Resolves from Theme namespace Use for
kui() app/views/kiso/components/ Kiso::Themes:: Kiso’s built-in components
appui() app/views/components/ AppThemes:: Your app’s custom components

Both helpers share the same override system (css_classes:, ui:, **component_options), the same kiso_prepare_options helper, and the same sub-part composition pattern. The only difference is where they look for partials and themes.

Use kui() for Kiso’s components. Use appui() for yours. Nest them however you like — a kui(:card) inside an appui(:pricing_card), or an appui(:member_badge) inside a kui(:table, :cell).


Choosing the right HTML element

The generated partials use <div> by default. Pick the right semantic element for your component:

Component type Element Why
Card, panel, container <div> Generic grouping
Status, label, badge <span> Inline content
Navigation wrapper <nav> Navigation landmark
List of items <ul> / <li> List semantics
Section with heading <section> Document outline
Sidebar <aside> Complementary content
Interactive element <button> Always use type: "button"

Change the tag in your partial by replacing :div in content_tag :div with the appropriate element.


Adding Stimulus behavior

Your app components can use Stimulus controllers just like Kiso’s built-in components. Add controller and action attributes via the data: hash:

<% # app/views/components/_copy_button.html.erb %>
<% # locals: (value:, css_classes: "", **component_options) %>
<%= content_tag :button,
    type: "button",
    class: AppThemes::CopyButton.render(class: css_classes),
    data: kiso_prepare_options(component_options, slot: "copy-button",
      controller: "clipboard",
      clipboard_value_value: value,
      action: "click->clipboard#copy"),
    **component_options do %>
  <%= yield %>
<% end %>

The kiso_prepare_options helper merges your data attributes with the caller’s data: hash, so both work together.


When to wrap vs. when to build standalone

Wrap a Kiso component when you’re adding domain logic on top of an existing component’s structure and styling. The wrapper translates your domain (a User, a Project, a Subscription) into Kiso’s props:

<% # This is a wrapper — it delegates all rendering to kui(:badge) %>
<%= kui(:badge, color: member.role_color, variant: :soft) do %>
  <%= member.role.titleize %>
<% end %>

Build standalone with appui() when you need:

  • Your own variant axes (:status, :priority, :tier)
  • Custom HTML structure that doesn’t map to any existing Kiso component
  • Sub-part composition for complex layouts
  • A component that other developers on your team will use across many views

You can always start as a wrapper and promote later. If a wrapper grows complex enough to need its own variants, generate a standalone component and move the logic there. The appui() call site looks almost identical to a wrapper’s render call, so the migration is straightforward.


Multiple themes

If your app needs different looks for different contexts (admin vs. marketing, or multi-tenant branding), use the --theme flag:

bin/rails generate kiso:component pricing_card --theme modern

This creates files in app/themes/modern/ instead of app/themes/default/. Switch themes in your initializer:

# config/initializers/kiso.rb
Kiso.configure do |config|
  config.app_theme = :modern  # loads from app/themes/modern/
end

All appui() calls automatically use the active theme’s definitions.