| name | rails-viewcomponents |
| description | Specialized skill for building ViewComponents with Hotwire (Turbo & Stimulus). Use when creating reusable UI components, implementing frontend interactions, building Turbo Frames/Streams, or writing Stimulus controllers. Includes component testing with Lookbook. |
Rails ViewComponents & Frontend
Build modern, component-based UIs with ViewComponent, Turbo, and Stimulus.
When to Use This Skill
- Creating ViewComponents
- Building Turbo Frames and Streams
- Writing Stimulus controllers
- Implementing custom confirmation modals
- Creating Lookbook previews
- Building form interactions
- Real-time updates with Turbo Streams
- Progressive enhancement with Stimulus
Core Principle: Component-Based Architecture
ALL UI components MUST be ViewComponents - not partials.
Why ViewComponents?
- ✓ Better encapsulation than partials
- ✓ Testable in isolation
- ✓ Object-oriented approach
- ✓ Type safety and contracts
- ✓ Performance benefits (compiled)
- ✓ IDE support
Critical ViewComponent Rules
1. Prefix Rails helpers with helpers.
<%# CORRECT %>
<%= helpers.link_to "Home", root_path %>
<%= helpers.image_tag "logo.png" %>
<%= helpers.inline_svg_tag "icons/user.svg" %>
<%# WRONG %>
<%= link_to "Home", root_path %>
Exception: t() i18n helper does NOT need prefix:
<%# CORRECT %>
<%= t('.title') %>
2. SVG Icons as Separate Files
Store SVGs in app/assets/images/icons/ and render with inline_svg gem:
<%= helpers.inline_svg_tag "icons/user.svg", class: "w-5 h-5" %>
NEVER inline SVG markup in Ruby code.
Quick Component Patterns
Basic Component
# app/components/button_component.rb
class ButtonComponent < ViewComponent::Base
def initialize(text:, variant: :primary, **options)
@text = text
@variant = variant
@options = options
end
end
<%# app/components/button_component.html.erb %>
<button class="btn btn-<%= @variant %>" <%= html_attributes(@options) %>>
<%= @text %>
</button>
Component with Slots
class CardComponent < ViewComponent::Base
renders_one :header
renders_one :footer
renders_many :actions
end
<%= render CardComponent.new do |card| %>
<% card.with_header do %>
<h3>Title</h3>
<% end %>
<p>Body content</p>
<% card.with_action do %>
<%= helpers.link_to "Edit", edit_path %>
<% end %>
<% end %>
Component with Variants
class BadgeComponent < ViewComponent::Base
VARIANTS = {
primary: "bg-blue-100 text-blue-800",
success: "bg-green-100 text-green-800",
danger: "bg-red-100 text-red-800"
}.freeze
def initialize(text:, variant: :primary)
@text = text
@variant = variant
end
def variant_classes
VARIANTS[@variant]
end
end
Turbo Frames & Streams
Turbo Frame (Edit in Place)
<%# index.html.erb %>
<%= turbo_frame_tag dom_id(article) do %>
<h2><%= article.title %></h2>
<%= link_to "Edit", edit_article_path(article) %>
<% end %>
<%# edit.html.erb %>
<%= turbo_frame_tag dom_id(@article) do %>
<%= form_with model: @article do |f| %>
<%= f.text_field :title %>
<%= f.submit %>
<% end %>
<% end %>
Turbo Streams (Multiple Updates)
# Controller
def create
@article = Article.new(article_params)
respond_to do |format|
if @article.save
format.turbo_stream do
render turbo_stream: [
turbo_stream.prepend("articles", partial: "article", locals: { article: @article }),
turbo_stream.update("form", partial: "form", locals: { article: Article.new })
]
end
end
end
end
Stimulus Controllers
Basic Controller
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
static values = { open: Boolean }
toggle() {
this.openValue = !this.openValue
}
openValueChanged() {
if (this.openValue) {
this.menuTarget.classList.remove("hidden")
} else {
this.menuTarget.classList.add("hidden")
}
}
}
<div data-controller="dropdown">
<button data-action="dropdown#toggle">Options</button>
<div data-dropdown-target="menu" class="hidden">
<a href="#">Edit</a>
</div>
</div>
Custom Confirmation Modals
ALWAYS use custom modals instead of browser confirm()
// app/javascript/controllers/confirmation_modal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "title", "message", "confirmButton"]
show(options = {}) {
this.titleTarget.textContent = options.title || "Confirm"
this.messageTarget.textContent = options.message || "Are you sure?"
this.modalTarget.classList.remove("hidden")
this.onConfirm = options.onConfirm || (() => {})
}
confirm() {
this.onConfirm()
this.hide()
}
hide() {
this.modalTarget.classList.add("hidden")
}
}
Lookbook Previews
Required for shared components:
# spec/components/previews/button_component_preview.rb
class ButtonComponentPreview < ViewComponent::Preview
def default
render ButtonComponent.new(text: "Click me")
end
def primary
render ButtonComponent.new(text: "Primary", variant: :primary)
end
def danger
render ButtonComponent.new(text: "Delete", variant: :danger)
end
end
Access at: http://localhost:3000/lookbook
Testing Components
RSpec.describe ButtonComponent, type: :component do
it "renders button text" do
render_inline(ButtonComponent.new(text: "Click me"))
expect(page).to have_button("Click me")
end
it "applies variant classes" do
render_inline(ButtonComponent.new(text: "Save", variant: :primary))
expect(page).to have_css("button.btn-primary")
end
end
Tech Stack
- ViewComponent - Component framework
- Lookbook - Component documentation
- Turbo - SPA-like interactions
- Stimulus - JavaScript controllers
- Tailwind CSS - Styling (typical)
- inline_svg - SVG rendering gem
Reference Documentation
For comprehensive frontend patterns:
- Frontend guide:
frontend.md(detailed examples and advanced patterns)