rails-components

Installation
SKILL.md

Rails Component Patterns

Build reusable, self-contained, composable UI components using Rails primitives — partials, CSS classes, and helpers.

Philosophy

Core Principles:

  1. Start with CSS classes — don't reach for a partial when a class will do
  2. Self-contained — each component owns its markup AND styles in one place
  3. Composable — components nest and combine naturally
  4. Consistent — same component = same markup = same appearance everywhere
  5. Progressive complexity — graduate from CSS → partial → helper only when needed

The Component Ladder:

ViewComponent (gem)     ← Complex: encapsulated Ruby + template + tests
Helper method           ← Frequent: shorthand for common partials
Partial with locals     ← Structured: logic, slots, complex markup
CSS classes only        ← Simple: single element, few variants

Always start at the bottom. Move up only when you feel pain.

When To Use This Skill

  • Building new reusable UI components (buttons, cards, modals, etc.)
  • Deciding between CSS classes, partials, helpers, or ViewComponent
  • Creating empty states, alerts, badges, or data tables
  • Refactoring repeated markup into shared components
  • Setting up a component gallery for development
  • Implementing variant patterns (sizes, colors, states)
  • Organizing component files and styles

Decision Framework

Use CSS Classes When:

  • Component is a single element or simple wrapper (<button>, <span>, <div>)
  • Variants are purely visual (color, size, spacing)
  • No conditional logic needed
  • No content slots beyond inner text

Examples: buttons, badges, simple cards, form inputs, dividers

Use Partials When:

  • Component has conditional sections (show/hide based on args)
  • Multiple named content areas (header, body, footer)
  • Non-trivial markup structure (5+ elements)
  • Default values or computed attributes needed

Examples: empty states, modals, complex cards, data tables, alerts with icons

Use Helpers When:

  • A partial is used very frequently (10+ call sites)
  • You want a cleaner Ruby API: ui_badge("Active", variant: :success)
  • Simple components where ERB render syntax feels heavy
  • You want to compose multiple elements in Ruby

Examples: badges, status indicators, icon buttons, breadcrumbs

Use ViewComponent When:

  • Component needs its own unit tests
  • Complex state logic that doesn't belong in a view
  • Team is large and components need strict interfaces
  • You're building a design system with dozens of components

Skip ViewComponent for most Rails apps. Partials + helpers cover 90% of needs.

Instructions

Step 1: Check Existing Components

ALWAYS look for existing patterns first:

# Find existing component partials
find app/views -path "*/components/*" -o -path "*/_component*" | head -20

# Find component CSS
find app/assets -name "*component*" -o -path "*/components/*" | head -20

# Find component helpers
rg "def ui_|def component_" app/helpers/

# Check for ViewComponent usage
ls app/components/ 2>/dev/null
rg "ViewComponent" Gemfile

Match existing project conventions. Consistency > your preference.

Step 2: Choose the Right Approach

Reference the decision framework above. Ask:

  1. Can this be done with just CSS classes? → Do that.
  2. Does it need conditional logic or slots? → Partial.
  3. Is it called from 10+ places with the same pattern? → Add a helper.
  4. Does it need isolated tests and complex state? → ViewComponent.

Step 3: File Organization

app/views/components/          # Shared component partials
├── _modal.html.erb
├── _empty_state.html.erb
├── _card.html.erb
├── _alert.html.erb
└── _data_table.html.erb

app/assets/stylesheets/components/   # One CSS file per component
├── buttons.css
├── badges.css
├── cards.css
├── modals.css
├── empty-states.css
├── alerts.css
└── tables.css

app/helpers/
└── component_helper.rb        # Helper shortcuts for common components

Naming conventions:

  • Partials: _snake_case.html.erb in app/views/components/
  • CSS files: kebab-case.css in app/assets/stylesheets/components/
  • Helpers: ui_component_name prefix in ComponentHelper
  • CSS classes: .component-name, .component-name-element, .component-name--variant

Step 4: Build CSS-Only Components

For simple components, define CSS classes and use directly in templates:

/* app/assets/stylesheets/components/buttons.css */
@layer components {
  .btn {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 1rem;
    border-radius: 0.375rem;
    font-weight: 500;
    cursor: pointer;
    transition: background-color 0.15s;
  }

  .btn-primary { background: var(--color-primary); color: white; }
  .btn-secondary { background: var(--color-surface); border: 1px solid var(--color-border); }
  .btn-danger { background: var(--color-danger); color: white; }
  .btn-ghost { background: transparent; }
  .btn-sm { padding: 0.25rem 0.75rem; font-size: 0.875rem; }
  .btn-lg { padding: 0.75rem 1.5rem; }
  .btn-icon { padding: 0.5rem; }
}
<%# Usage — no partial needed %>
<button class="btn btn-primary">Save</button>
<button class="btn btn-danger btn-sm">Delete</button>
<%= link_to "View", post_path(@post), class: "btn btn-secondary" %>

Step 5: Build Partial-Based Components

Every partial MUST have a comment block documenting its interface:

<%# app/views/components/_empty_state.html.erb %>
<%#
  Empty State Component

  Arguments:
    icon:        (String) Icon name — required
    title:       (String) Main heading — required
    description: (String, optional) Supporting text
    class:       (String, optional) Additional CSS classes

  Block: Optional action buttons
%>
<div class="empty-state <%= local_assigns[:class] %>">
  <div class="empty-state-icon">
    <%= lucide_icon icon, size: 48 %>
  </div>

  <h3 class="empty-state-title"><%= title %></h3>

  <% if local_assigns[:description] %>
    <p class="empty-state-description"><%= description %></p>
  <% end %>

  <% if block_given? %>
    <div class="empty-state-actions">
      <%= yield %>
    </div>
  <% end %>
</div>

Key rules for partials:

  1. Use local_assigns[:key] to check for optional args — NOT defined? or if key
  2. Use ||= for defaults when the arg should have a fallback value
  3. Use block_given? to check if a block was passed
  4. Use yield for the primary content slot
  5. Use capture for additional named slots

Step 6: Implement Slots with Capture

For components with multiple content areas:

<%# app/views/components/_card.html.erb %>
<%#
  Card Component

  Arguments:
    header:  (String|HTML, optional) Header content — use capture for complex HTML
    footer:  (String|HTML, optional) Footer content — use capture for complex HTML
    class:   (String, optional) Additional CSS classes
    variant: (Symbol, optional) Card variant — :default, :stat, :interactive

  Block: Card body content (required)
%>
<%
  variant ||= :default
  css_class = ["card", ("card-#{variant}" unless variant == :default), local_assigns[:class]].compact.join(" ")
%>
<div class="<%= css_class %>">
  <% if local_assigns[:header] %>
    <div class="card-header"><%= header %></div>
  <% end %>

  <div class="card-body">
    <%= yield %>
  </div>

  <% if local_assigns[:footer] %>
    <div class="card-footer"><%= footer %></div>
  <% end %>
</div>

Caller uses capture for rich slot content:

<%= render "components/card",
    header: capture { tag.h3("Settings", class: "card-title") },
    footer: capture {
      link_to("Cancel", "#", class: "btn btn-secondary") +
      link_to("Save", "#", class: "btn btn-primary")
    } do %>
  <p>Card body content here.</p>
<% end %>

Step 7: Add Helper Shortcuts

For frequently-used components, create helpers:

# app/helpers/component_helper.rb
module ComponentHelper
  def ui_badge(text, variant: :muted)
    tag.span(text, class: "badge badge-#{variant}")
  end

  def ui_empty_state(title:, icon: nil, description: nil, &block)
    render("components/empty_state",
      title: title,
      icon: icon,
      description: description,
      &block)
  end

  def ui_alert(variant: :info, dismissible: false, &block)
    render("components/alert",
      variant: variant,
      dismissible: dismissible,
      &block)
  end
end

Usage becomes clean:

<%= ui_badge "Active", variant: :success %>
<%= ui_badge "Draft" %>

<%= ui_empty_state(
  icon: "inbox",
  title: "No messages",
  description: "Check back later") %>

Step 8: Handle Variants

CSS variants for purely visual changes:

/* Size variants */
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.875rem; }
.btn-lg { padding: 0.75rem 1.5rem; }

/* State variants */
.card-interactive:hover { border-color: var(--color-border-strong); }
.card-flush .card-body { padding: 0; }

Partial argument variants when markup changes:

<%# In the partial %>
<%
  variant ||= :info
  icon_name = { success: "check-circle", error: "alert-circle",
                warning: "alert-triangle", info: "info" }[variant.to_sym]
%>
<div class="alert alert-<%= variant %>" role="alert">
  <%= lucide_icon icon_name, size: 18 %>
  <span class="alert-content"><%= yield %></span>
</div>

Step 9: Create a Component Gallery

Essential for development. Let anyone browse all components:

# config/routes.rb
if Rails.env.development?
  get "components", to: "components#index"
end
# app/controllers/components_controller.rb
class ComponentsController < ApplicationController
  layout "application"

  def index
  end
end
<%# app/views/components/index.html.erb %>
<div style="max-width: 48rem; margin: 2rem auto; padding: 0 1rem;">
  <h1>Component Gallery</h1>

  <h2>Buttons</h2>
  <div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 2rem;">
    <button class="btn btn-primary">Primary</button>
    <button class="btn btn-secondary">Secondary</button>
    <button class="btn btn-danger">Danger</button>
    <button class="btn btn-ghost">Ghost</button>
    <button class="btn btn-sm">Small</button>
  </div>

  <h2>Badges</h2>
  <div style="display: flex; gap: 0.5rem; margin-bottom: 2rem;">
    <span class="badge badge-muted">Draft</span>
    <span class="badge badge-success">Active</span>
    <span class="badge badge-warning">Pending</span>
    <span class="badge badge-danger">Failed</span>
  </div>

  <h2>Empty States</h2>
  <%= render "components/empty_state",
      icon: "inbox",
      title: "No messages yet",
      description: "When you receive messages, they'll appear here." do %>
    <button class="btn btn-primary">Compose</button>
  <% end %>

  <h2>Alerts</h2>
  <%= render "components/alert", variant: :info do %>Info message<% end %>
  <%= render "components/alert", variant: :success do %>Success message<% end %>
  <%= render "components/alert", variant: :warning do %>Warning message<% end %>
  <%= render "components/alert", variant: :error do %>Error message<% end %>
</div>

Anti-Patterns

  1. Partial for everything — A <span class="badge"> does NOT need render "components/badge". CSS class is enough.
  2. Using defined?(variable) — Always use local_assigns[:variable] in partials. defined? is unreliable.
  3. Global styles for component-specific things — Component CSS lives in components/. Don't pollute application.css.
  4. Inconsistent naming — Pick a convention and stick with it. _empty_state.html.erb not _emptyState.html.erb.
  5. God components — If a partial takes 10+ arguments, split it into smaller components.
  6. Missing documentation — Every partial needs a comment block listing its arguments.
  7. Passing HTML strings as args — Use capture { } blocks or yield, never raw HTML strings.
  8. No gallery page — If you can't see all your components in one place, they'll diverge.

CSS Conventions

/* Use @layer for specificity management */
@layer components {
  .component-name { /* base styles */ }
  .component-name-element { /* child element */ }
  .component-name--variant { /* BEM variant — OR use component-variant */ }
}

Design token usage:

.component {
  color: var(--color-ink);           /* Text colors */
  background: var(--color-surface);  /* Backgrounds */
  border: 1px solid var(--color-border);  /* Borders */
  padding: var(--space-4);           /* Spacing scale */
  font-size: var(--text-sm);         /* Type scale */
  border-radius: var(--radius-md);   /* Border radius */
}

If the project uses design tokens/CSS custom properties, use them. If not, use consistent raw values.

Quick Reference

local_assigns Cheat Sheet

<%# Check if argument was passed (even if nil) %>
<% if local_assigns.key?(:title) %>

<%# Check if argument was passed AND is truthy %>
<% if local_assigns[:title] %>

<%# Default value %>
<% variant = local_assigns[:variant] || :default %>
<%# OR %>
<% variant ||= :default %>

<%# Pass-through CSS classes %>
<div class="component <%= local_assigns[:class] %>">

Render Syntax

<%# Simple — no block %>
<%= render "components/empty_state", icon: "inbox", title: "Empty" %>

<%# With block %>
<%= render "components/modal", title: "Confirm" do %>
  <p>Are you sure?</p>
<% end %>

<%# With capture slots %>
<%= render "components/card",
    header: capture { tag.h3("Title") },
    footer: capture { tag.button("Save", class: "btn btn-primary") } do %>
  <p>Body</p>
<% end %>

Common Component Templates

See reference.md in this skill directory for complete templates of:

  • Buttons, Badges (CSS-only)
  • Cards, Empty States, Alerts (partial)
  • Modals with Stimulus (partial + JS)
  • Data Tables (partial)
  • Helper methods for all of the above
Related skills

More from thinkoodle/rails-skills

Installs
4
GitHub Stars
4
First Seen
9 days ago