rails-components
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
More from thinkoodle/rails-skills
minitest
Expert guidance for writing fast, maintainable Minitest tests in Rails applications. Use when writing tests, converting from RSpec, debugging test failures, improving test performance, or following testing best practices. Covers model tests, policy tests, request tests, system tests, fixtures, and TDD workflows.
32caching
Expert guidance for Rails caching — fragment caching, Russian doll caching, cache keys/versioning, low-level caching (Rails.cache), conditional GET (stale?/fresh_when), and cache stores (Solid Cache, Redis, Memcached). Use when implementing cache, caching, fragment cache, Russian doll, Rails.cache, Solid Cache, cache key, HTTP caching, stale?, fresh_when, cache store, or optimizing performance.
4uuid-primary-keys
Expert guidance for implementing UUID primary keys in Rails applications. Use when setting up UUIDs as primary keys, choosing between UUIDv4 and UUIDv7, configuring generators for UUID defaults, writing migrations with id colon uuid, adding UUID foreign keys, implementing base36 encoding for URL-friendly IDs, configuring PostgreSQL pgcrypto or gen_random_uuid, implementing SQLite binary UUID storage, choosing a primary key type, using non-sequential IDs, secure IDs, random IDs, or any ID generation strategy beyond auto-increment integers.
4security
Expert guidance for writing secure Rails applications. Use when dealing with security, CSRF protection, XSS prevention, SQL injection, authentication, authorization, sanitize, html_safe, credentials, secrets, content security policy, session security, mass assignment, strong parameters, secure headers, file uploads, open redirects, or vulnerability remediation. Covers every major attack vector and the Rails-idiomatic defenses.
4stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4