Rails Component Patterns
Build reusable, self-contained, composable UI components using Rails primitives — partials, CSS classes, and helpers.
Philosophy
Core Principles:
- Start with CSS classes — don't reach for a partial when a class will do
- Self-contained — each component owns its markup AND styles in one place
- Composable — components nest and combine naturally
- Consistent — same component = same markup = same appearance everywhere
- 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:
- Can this be done with just CSS classes? → Do that.
- Does it need conditional logic or slots? → Partial.
- Is it called from 10+ places with the same pattern? → Add a helper.
- 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.erbinapp/views/components/ - CSS files:
kebab-case.cssinapp/assets/stylesheets/components/ - Helpers:
ui_component_nameprefix inComponentHelper - 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:
- Use
local_assigns[:key]to check for optional args — NOTdefined?orif key - Use
||=for defaults when the arg should have a fallback value - Use
block_given?to check if a block was passed - Use
yieldfor the primary content slot - Use
capturefor 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
- Partial for everything — A
<span class="badge">does NOT needrender "components/badge". CSS class is enough. - Using
defined?(variable)— Always uselocal_assigns[:variable]in partials.defined?is unreliable. - Global styles for component-specific things — Component CSS lives in
components/. Don't polluteapplication.css. - Inconsistent naming — Pick a convention and stick with it.
_empty_state.html.erbnot_emptyState.html.erb. - God components — If a partial takes 10+ arguments, split it into smaller components.
- Missing documentation — Every partial needs a comment block listing its arguments.
- Passing HTML strings as args — Use
capture { }blocks oryield, never raw HTML strings. - 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