
Rails Design System
- 215 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
rails-design-system: A skill for development. This provides functionality for development workflows.
Key points
- rails-design-system
Rails Design System by the numbers
- 215 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,864 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill rails-design-systemAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 215 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use rails-design-system for development tasks?
Use rails-design-system for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with rails-design-system.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use rails-design-system for development tasks, or when rails-design-system: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to rails-design-system: rails-design-system.
Files
Community Ruby on Rails Design System Best Practices
Comprehensive design system guide for Ruby on Rails applications, maintained by Community. Contains 51 rules across 9 categories, prioritized by impact to guide automated refactoring and code generation. Covers the full Rails frontend stack: Turbo (Drive, Frames, Streams), Stimulus, ERB partials, design tokens, form builders, and view helpers. Complements rails-dev (controllers, models, queries) and tailwind (CSS patterns) by covering the systematic UI component architecture layer.
When to Apply
Reference these guidelines when:
- Deciding whether to extract a partial, component, or helper
- Defining design tokens with Tailwind CSS
@theme - Creating or refactoring ERB partials with explicit locals
- Decomposing pages into Turbo Frames for targeted updates
- Using Turbo Streams for multi-element CRUD updates
- Coordinating Turbo navigation with Stimulus controllers
- Building ViewComponent or Phlex components for complex UI
- Implementing a custom FormBuilder for consistent forms
- Writing view helpers for badges, icons, and conditional classes
- Adding Stimulus controllers for interactive behaviors
- Managing JavaScript dependencies with Import Maps
- Auditing the codebase for UI duplication and naming drift
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Design Decisions | CRITICAL | decide- |
| 2 | Design Tokens | CRITICAL | token- |
| 3 | Turbo Integration | HIGH | turbo- |
| 4 | Partial Patterns | HIGH | partial- |
| 5 | Component Architecture | HIGH | comp- |
| 6 | Form System | MEDIUM-HIGH | form- |
| 7 | Helper Patterns | MEDIUM | helper- |
| 8 | Stimulus Behaviors | MEDIUM | stim- |
| 9 | Consistency & Organization | LOW-MEDIUM | org- |
Quick Reference
1. Design Decisions (CRITICAL)
- `decide-three-uses-rule` - Extract only after a pattern appears in 3+ places
- `decide-partial-vs-component` - Choose partials for simple reuse, components for complex logic
- `decide-helper-vs-partial` - Use helpers for tiny HTML fragments, partials for layout blocks
- `decide-prove-then-extract` - Prove patterns in production before abstracting
- `decide-avoid-wrapper-components` - Avoid thin wrappers that add indirection without value
- `decide-design-system-scope` - Scope the design system to what the app actually needs
2. Design Tokens (CRITICAL)
- `token-tailwind-theme` - Define tokens with Tailwind CSS @theme directive
- `token-semantic-color-names` - Name colors by purpose, not appearance
- `token-spacing-scale` - Use a constrained spacing scale for consistent layout
- `token-typography-scale` - Define a typography scale for headings, body, and UI text
- `token-component-tokens` - Create component-level tokens for repeated patterns
- `token-share-tokens-with-ruby` - Share token values between CSS and Ruby when needed
3. Turbo Integration (HIGH)
- `turbo-drive-defaults` - Let Turbo Drive handle navigation by default
- `turbo-frame-decompose` - Decompose pages into Turbo Frames for targeted updates
- `turbo-frame-naming` - Name Turbo Frames with dom_id conventions
- `turbo-frame-vs-stream` - Choose Turbo Frames vs Turbo Streams by scope of change
- `turbo-stream-crud` - Use Turbo Streams for multi-element page updates
- `turbo-stimulus-coordination` - Coordinate Turbo and Stimulus without conflicts
4. Partial Patterns (HIGH)
- `partial-explicit-locals` - Always pass locals explicitly to partials
- `partial-presenter-objects` - Use presenter objects to encapsulate view logic
- `partial-naming-conventions` - Name partials by what they render, prefixed with underscore
- `partial-yield-blocks` - Use yield blocks for flexible partial layouts
- `partial-collection-with-spacer` - Use collection rendering with spacer templates
- `partial-shared-directory` - Place cross-controller partials in app/views/shared
5. Component Architecture (HIGH)
- `comp-when-to-use` - Use components when partials outgrow simple rendering
- `comp-explicit-args` - Define explicit typed arguments for every component
- `comp-slots-for-markup` - Use slots for caller-provided markup blocks
- `comp-test-rendered-output` - Test components by asserting on rendered HTML
6. Form System (MEDIUM-HIGH)
- `form-custom-builder` - Create a custom FormBuilder for consistent form rendering
- `form-set-default-builder` - Set the custom builder as the application default
- `form-error-display` - Display field errors inline with consistent markup
- `form-accessible-labels` - Generate accessible labels and ARIA attributes automatically
- `form-group-wrapper` - Wrap label + input + error in a consistent group element
- `form-button-consistency` - Standardize submit buttons through the form builder
7. Helper Patterns (MEDIUM)
- `helper-tag-helpers` - Use tag helpers for small generated HTML fragments
- `helper-conditional-classes` - Use class_names for conditional CSS classes
- `helper-icon-helper` - Create an icon helper for consistent icon rendering
- `helper-badge-pattern` - Build a badge helper for status indicators
- `helper-scope-to-domain` - Scope helpers to specific domains, not generic utilities
8. Stimulus Behaviors (MEDIUM)
- `stim-general-purpose` - Write general-purpose controllers, not one-off scripts
- `stim-data-attribute-config` - Configure behavior through data attributes, not JavaScript
- `stim-small-controllers` - Keep controllers small and single-responsibility
- `stim-composable-controllers` - Compose multiple controllers on one element
- `stim-use-outlets` - Use outlets for cross-controller communication
- `stim-leverage-library` - Use stimulus-components before writing custom controllers
9. Consistency & Organization (LOW-MEDIUM)
- `org-naming-conventions` - Follow consistent naming across partials, components, and helpers
- `org-file-structure` - Organize design system files in predictable locations
- `org-deduplication-audit` - Periodically audit views for duplicated patterns
- `org-import-maps` - Use Import Maps for zero-build JavaScript delivery
- `org-preview-with-lookbook` - Preview components with Lookbook in development
- `org-document-design-decisions` - Document design system decisions in ADRs
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on maintainability, consistency, or performance implications for the design system.}
Incorrect ({what's wrong}):
{Bad code example - production-realistic, not strawman}
{<%# Comments explaining the cost %>}Correct ({what's right}):
{Good code example - minimal diff from incorrect}
{<%# Comments explaining the benefit %>}{Optional sections as needed:}
Alternative ({context}):
{Alternative approach when applicable}When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Benefits:
- {Benefit 1}
- {Benefit 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "1.0.5",
"organization": "Community",
"technology": "Ruby on Rails Design System",
"date": "February 2026",
"abstract": "Comprehensive design system guide for Ruby on Rails applications, designed for AI agents and LLMs. Contains 51 rules across 9 categories, prioritized by impact from critical (design decisions, design tokens) to incremental (consistency and organization). Covers the full Rails frontend stack: Turbo (Drive, Frames, Streams), Stimulus, ERB partials, Tailwind design tokens, form builders, and view helpers. Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://turbo.hotwired.dev/handbook/introduction",
"https://guides.rubyonrails.org/layouts_and_rendering.html",
"https://viewcomponent.org/",
"https://www.phlex.fun/",
"https://stimulus.hotwired.dev/",
"https://lookbook.build/",
"https://tailwindcss.com/docs/theme",
"https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html",
"https://github.com/stimulus-components/stimulus-components",
"https://github.com/rails/importmap-rails",
"https://dev.37signals.com/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Design Decisions (decide)
Impact: CRITICAL Description: Premature abstraction is the #1 design system killer in Rails. Knowing WHEN to extract a partial, component, or helper — and when to leave inline HTML alone — prevents codebase bloat and yields the largest maintainability gains.
2. Design Tokens (token)
Impact: CRITICAL Description: A shared vocabulary of colors, spacing, typography, and radii eliminates visual inconsistency at the root. Tokens defined once propagate everywhere, making sweeping visual changes a single-line edit.
3. Turbo Integration (turbo)
Impact: HIGH Description: Turbo Drive, Turbo Frames, and Turbo Streams are the core Rails frontend stack for page navigation, partial updates, and real-time CRUD. Proper Turbo patterns eliminate custom AJAX and JavaScript routing while giving SPA-like speed.
4. Partial Patterns (partial)
Impact: HIGH Description: ERB partials handle 80% of Rails view reuse. Well-structured partials with explicit locals, collection rendering, and presenter objects reduce duplication without introducing component framework overhead.
5. Component Architecture (comp)
Impact: HIGH Description: ViewComponent and Phlex provide testable, encapsulated view units for the 20% of UI that outgrows partials — complex conditional rendering, multi-slot layouts, and cross-cutting patterns used in 3+ contexts.
6. Form System (form)
Impact: MEDIUM-HIGH Description: A custom FormBuilder enforces consistent labels, error display, help text, and accessibility across every form in the application. Forms are the highest-interaction surface and the most duplicated UI pattern.
7. Helper Patterns (helper)
Impact: MEDIUM Description: View helpers generate small HTML fragments with logic — badges, status indicators, conditional classes. They fill the gap between inline ERB and full partials for output that is too small to warrant a file.
8. Stimulus Behaviors (stim)
Impact: MEDIUM Description: Stimulus controllers provide the JavaScript behavior layer — toggles, dropdowns, copy-to-clipboard, form validation. Small, composable controllers replace JavaScript soup with declarative data attributes.
9. Consistency & Organization (org)
Impact: LOW-MEDIUM Description: Naming conventions, file structure, Import Maps, Lookbook documentation, and deduplication audits prevent design system drift as the team and codebase grow.
Pass All Data Through Constructor Arguments
Components must receive all data via initialize arguments. Never access controller instance variables, params, Current.user, or any global state directly from inside a component. This makes components fully portable between controllers, testable in isolation, and self-documenting through their constructor signature.
Incorrect (hidden dependencies on global state):
# app/components/sidebar_component.rb
class SidebarComponent < ViewComponent::Base
def initialize
@user = Current.user
@notifications = Current.user.notifications.unread
@show_admin = params[:admin].present?
end
def render?
@user.present?
end
endThis component cannot be tested without setting up Current.user and params. It also breaks if rendered in a context where Current is not configured (mailers, background jobs generating HTML).
Correct (explicit constructor arguments):
# app/components/sidebar_component.rb
class SidebarComponent < ViewComponent::Base
def initialize(user:, notifications:, show_admin: false)
@user = user
@notifications = notifications
@show_admin = show_admin
end
def render?
@user.present?
end
def unread_count
@notifications.size
end
def admin_section?
@show_admin && @user.admin?
end
end<%# app/views/layouts/application.html.erb %>
<%= render(SidebarComponent.new(
user: current_user,
notifications: current_user.notifications.unread,
show_admin: current_user.admin?
)) %>Testing With Explicit Arguments
Explicit arguments make component tests straightforward and fast:
# test/components/sidebar_component_test.rb
class SidebarComponentTest < ViewComponent::TestCase
test "does not render without user" do
result = render_inline(SidebarComponent.new(user: nil, notifications: []))
assert_no_selector ".sidebar"
end
test "shows unread notification count" do
user = build(:user)
notifications = build_list(:notification, 3)
render_inline(SidebarComponent.new(user: user, notifications: notifications))
assert_selector ".unread-badge", text: "3"
end
test "hides admin section for non-admin users" do
user = build(:user, admin: false)
render_inline(SidebarComponent.new(user: user, notifications: [], show_admin: true))
assert_no_selector ".admin-section"
end
endNo Current stubbing, no params mocking, no controller context needed.
Reference: ViewComponent Guide
Use Slots for Markup, Arguments for Data
If a consumer needs to pass HTML or markup into a component, use a slot. If they pass a string, boolean, number, or object, use a constructor argument. This distinction prevents callers from building HTML strings and marking them html_safe, which is error-prone and bypasses Rails' XSS protection.
Incorrect (passing markup as string arguments):
# app/components/card_component.rb
class CardComponent < ViewComponent::Base
def initialize(title:, footer_html: nil)
@title = title
@footer_html = footer_html
end
end<%# Caller must build HTML strings — fragile and unsafe %>
<%= render(CardComponent.new(
title: "Settings",
footer_html: "<button class='btn btn-primary'>Save</button>".html_safe
)) %>Correct (slots for markup, arguments for data):
# app/components/card_component.rb
class CardComponent < ViewComponent::Base
renders_one :header
renders_one :footer
renders_many :actions
def initialize(title:, variant: :default)
@title = title
@variant = variant
end
def variant_class
case @variant
when :elevated then "card-elevated shadow-lg"
when :outlined then "card-outlined border"
else "card-default"
end
end
end<%# app/components/card_component.html.erb %>
<div class="card <%= variant_class %>">
<div class="card-header">
<% if header? %>
<%= header %>
<% else %>
<h3 class="card-title"><%= @title %></h3>
<% end %>
</div>
<div class="card-body">
<%= content %>
</div>
<% if footer? %>
<div class="card-footer">
<%= footer %>
</div>
<% end %>
</div><%# Usage — callers write real ERB, not HTML strings %>
<%= render(CardComponent.new(title: "User Settings", variant: :elevated)) do |card| %>
<% card.with_footer do %>
<%= link_to "Cancel", settings_path, class: "btn btn-secondary" %>
<%= button_tag "Save Changes", class: "btn btn-primary",
data: { action: "submit" } %>
<% end %>
<div class="form-group">
<%= label_tag :name, "Display Name" %>
<%= text_field_tag :name, @user.name, class: "form-control" %>
</div>
<% end %>renders_one vs renders_many
class NavigationComponent < ViewComponent::Base
renders_one :brand # single slot: logo/brand area
renders_many :items # multi slot: nav items
def initialize(sticky: false)
@sticky = sticky
end
end<%= render(NavigationComponent.new(sticky: true)) do |nav| %>
<% nav.with_brand do %>
<%= image_tag "logo.svg", alt: "AppName", class: "h-8" %>
<% end %>
<% nav.with_item do %>
<%= link_to "Dashboard", dashboard_path %>
<% end %>
<% nav.with_item do %>
<%= link_to "Settings", settings_path %>
<% end %>
<% end %>Reference: ViewComponent Slots
Test Component Output, Not Internal Methods
Use render_inline and assert against the rendered HTML output with Capybara matchers. Do not test private helper methods directly with send or by making them public for testing. Testing the output means your tests verify what the user actually sees and survive internal refactoring. If you rename a private method or change how a CSS class is computed, the test still passes as long as the output is correct.
Incorrect (testing internal methods):
# test/components/user_badge_component_test.rb
class UserBadgeComponentTest < ViewComponent::TestCase
test "returns correct badge class for admin" do
user = build(:user, role: "admin")
component = UserBadgeComponent.new(user: user)
# Testing a private method — breaks if you rename or refactor it
assert_equal "badge-admin", component.send(:badge_class)
end
test "returns correct label for admin" do
user = build(:user, role: "admin")
component = UserBadgeComponent.new(user: user)
assert_equal "Administrator", component.send(:role_label)
end
endCorrect (testing rendered output):
# test/components/user_badge_component_test.rb
class UserBadgeComponentTest < ViewComponent::TestCase
test "renders admin badge with correct styling and label" do
user = build(:user, role: "admin")
render_inline(UserBadgeComponent.new(user: user))
assert_selector ".badge-admin", text: "Administrator"
end
test "renders member badge for regular users" do
user = build(:user, role: "member")
render_inline(UserBadgeComponent.new(user: user))
assert_selector ".badge-member", text: "Member"
end
test "does not render when user has no role" do
user = build(:user, role: nil)
render_inline(UserBadgeComponent.new(user: user))
assert_no_selector ".badge"
end
endTesting Slots
# test/components/card_component_test.rb
class CardComponentTest < ViewComponent::TestCase
test "renders card with title and body content" do
render_inline(CardComponent.new(title: "Settings")) do
"Your account settings"
end
assert_selector ".card-header h3", text: "Settings"
assert_selector ".card-body", text: "Your account settings"
end
test "renders footer slot when provided" do
render_inline(CardComponent.new(title: "Settings")) do |card|
card.with_footer { "Last updated: today" }
"Body content"
end
assert_selector ".card-footer", text: "Last updated: today"
end
test "does not render footer when slot is empty" do
render_inline(CardComponent.new(title: "Settings")) do
"Body content"
end
assert_no_selector ".card-footer"
end
endTesting Conditional Rendering
# test/components/sidebar_component_test.rb
class SidebarComponentTest < ViewComponent::TestCase
test "does not render when user is nil" do
render_inline(SidebarComponent.new(user: nil, notifications: []))
# render? returns false — component produces empty string
assert_equal "", rendered_content.strip
end
test "shows notification count badge" do
user = build(:user)
notifications = build_list(:notification, 5, :unread)
render_inline(SidebarComponent.new(user: user, notifications: notifications))
assert_selector "[data-testid='notification-badge']", text: "5"
end
endKey Assertions
| Assertion | Use when |
|---|---|
assert_selector ".class", text: "..." | Verify element exists with content |
assert_no_selector ".class" | Verify element does not render |
assert_text "..." | Verify text appears anywhere in output |
assert_link "...", href: "..." | Verify a link with specific href |
assert_selector "[data-action='...']" | Verify Stimulus bindings |
Reference: ViewComponent Testing Guide
Use Components Only When Partials Fall Short
Upgrade from a partial to a ViewComponent or Phlex component when you hit one of four concrete thresholds. Components add file overhead (class + template + test + preview), so they must earn their existence. Most Rails views work perfectly with well-structured partials. Reserve components for genuinely complex UI elements.
Incorrect (over-engineered component):
# app/components/alert_component.rb
# This is a 2-file component for what should be a 3-line partial
class AlertComponent < ViewComponent::Base
def initialize(type:, message:)
@type = type
@message = message
end
end<%# app/components/alert_component.html.erb %>
<div class="alert alert-<%= @type %>" role="alert">
<%= @message %>
</div>A partial handles this with zero overhead:
<%# app/views/shared/_alert.html.erb %>
<%# locals: (type:, message:) %>
<div class="alert alert-<%= type %>" role="alert">
<%= message %>
</div>Correct (component that earns its weight):
# app/components/modal_component.rb
class ModalComponent < ViewComponent::Base
renders_one :header
renders_one :body
renders_one :footer
def initialize(size: :md, dismissable: true)
@size = size
@dismissable = dismissable
end
def size_class
case @size
when :sm then "modal-sm"
when :lg then "modal-lg"
when :xl then "modal-xl"
else "modal-md"
end
end
end<%# Usage — multiple slots, conditional logic, reusable across the app %>
<%= render(ModalComponent.new(size: :lg)) do |modal| %>
<% modal.with_header do %>
<h2>Confirm Cancellation</h2>
<% end %>
<% modal.with_body do %>
<p>This will cancel the subscription immediately. Unused time will not be refunded.</p>
<% end %>
<% modal.with_footer do %>
<%= button_tag "Keep Subscription", class: "btn btn-secondary", data: { action: "modal#close" } %>
<%= button_to "Cancel Subscription", cancel_subscription_path, method: :patch, class: "btn btn-danger" %>
<% end %>
<% end %>Decision Checklist: When to Use a Component
| Criterion | Partial | Component |
|---|---|---|
| Simple markup with data locals | Yes | Overkill |
| Unit testing view logic in isolation | Awkward | Built-in |
| Multiple named content slots | content_for hacks | renders_one / renders_many |
| 3+ conditional rendering branches | Gets messy | Clean with Ruby methods |
| Preview/documentation system (Lookbook) | Not supported | First-class support |
| Used in 5+ places with strict API contract | Fragile | Enforced via initializer |
If a partial meets none of these criteria, keep it as a partial.
ViewComponent vs Phlex
If you do need a component, both ViewComponent and Phlex are production-grade options:
| Criterion | ViewComponent | Phlex |
|---|---|---|
| Team prefers ERB templates | Better fit | Unfamiliar syntax |
| Many small UI components | 2+ files per component | Single Ruby file |
| Need Lookbook previews | First-class support | Supported via adapter |
| Performance-sensitive rendering | Good | Faster (no template compilation) |
| Existing large ViewComponent library | Keep it | Migration cost |
| New project, Ruby-fluent team | Either works | Consider strongly |
Choose based on team preference and existing investment. Do not mix both in the same project unless migrating.
Reference: ViewComponent Documentation, Phlex Documentation
Avoid Wrapper Components That Add No Logic
A component that only wraps a CSS class around its content adds a file, a class definition, and a layer of indirection for zero functional value. With Tailwind, the utility classes already serve as a declarative API for styling. Creating CardComponent, ContainerComponent, or SectionComponent just to apply CSS classes trades one line of HTML for an entire Ruby class and template pair.
Incorrect (wrapper component that only adds CSS classes):
# app/components/card_component.rb — adds nothing but indirection
class CardComponent < ViewComponent::Base
def initialize(padding: 4)
@padding = padding
end
def call
content_tag :div, content, class: "rounded-lg border border-gray-200 p-#{@padding} shadow-sm"
end
end<%# Usage — now you need to look up CardComponent to understand what it renders %>
<%= render CardComponent.new(padding: 6) do %>
<h3 class="text-lg font-semibold"><%= @project.name %></h3>
<p class="text-sm text-gray-500"><%= @project.description %></p>
<% end %>Correct (Tailwind classes directly in ERB):
<%# app/views/projects/_card.html.erb — clear, no indirection %>
<div class="rounded-lg border border-gray-200 p-6 shadow-sm">
<h3 class="text-lg font-semibold text-gray-900"><%= project.name %></h3>
<p class="mt-1 text-sm text-gray-500"><%= truncate(project.description, length: 120) %></p>
<div class="mt-3 flex items-center gap-2">
<%= status_badge(project.status) %>
<span class="text-xs text-gray-400"><%= time_ago_in_words(project.updated_at) %> ago</span>
</div>
</div>When NOT to use this pattern: Wrap in a component when it adds real logic beyond CSS -- conditional rendering based on user permissions, data transformation, multiple named slots for different content areas, or Stimulus controller wiring. For example, a NotificationComponent that selects icon, color scheme, and dismiss behavior based on a severity level earns its abstraction.
Reference: ViewComponent Documentation — When to use components
Scope Your Design System to Actual Needs
A Rails design system is not a React component library. Over-scoping is the second most common failure mode after premature extraction. You need a small, focused toolkit: design tokens, a custom form builder, 5-10 shared partials or components, a handful of Stimulus controllers, and naming conventions. You do not need Storybook, a component API layer, a token management pipeline, or a separate design system application.
Incorrect (over-engineered design system scope):
# 50+ components built before any feature uses them
# app/components/
# accordion_component.rb
# alert_component.rb
# avatar_component.rb
# badge_component.rb
# breadcrumb_component.rb
# button_component.rb (3 variants nobody requested)
# card_component.rb
# carousel_component.rb (no carousel in any design)
# checkbox_component.rb
# ... 40 more files
# config/design_system.yml — token management pipeline
design_tokens:
pipeline: figma -> style-dictionary -> tailwind -> rails
sync_interval: daily
approval_workflow: true
# Gemfile — heavyweight dependencies for "just in case"
gem "lookbook"
gem "view_component"
gem "view_component-storybook" # Storybook bridge
gem "primer_view_components" # GitHub's component libraryCorrect (just enough for a production Rails app):
/* app/assets/stylesheets/application.css */
@import "tailwindcss";
/* Design tokens via Tailwind @theme — single source of truth */
@theme {
--color-primary: #4f46e5;
--color-primary-hover: #4338ca;
--color-danger: #ef4444;
--color-success: #22c55e;
--radius-default: 0.5rem;
--radius-full: 9999px;
}# app/form_builders/application_form_builder.rb
# Custom FormBuilder — consistent labels, errors, help text everywhere
class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
field_wrapper(method, options) { super(method, options.merge(class: field_classes(method))) }
end
private
def field_wrapper(method, options, &block)
@template.tag.div(class: "mb-4") do
label(method, class: "block text-sm font-medium text-gray-700") +
yield +
error_message(method)
end
end
end<%# 6-8 proven partials — each extracted after 3+ uses %>
<%# app/views/shared/_modal.html.erb %>
<%# app/views/shared/_flash.html.erb %>
<%# app/views/shared/_empty_state.html.erb %>
<%# app/views/shared/_pagination.html.erb %>
<%# app/views/shared/_dropdown_menu.html.erb %>
<%# app/views/shared/_page_header.html.erb %>When NOT to use this pattern: Large organizations with multiple Rails applications sharing a UI (e.g., a main app, an admin panel, and a marketing site) may benefit from a shared component gem or engine. In that case, the overhead of a more structured design system is justified by cross-application consistency. Even then, extract from one proven app first rather than designing in the abstract.
Reference: DHH -- The Rails Doctrine
Use Helpers for Fragments, Partials for Blocks
Creating a partial file for a single HTML element adds filesystem noise and template-rendering overhead for no benefit. Helpers are purpose-built for small HTML fragments with logic -- badges, status dots, icons, formatted labels. Reserve partials for multi-element blocks with structural markup. The threshold is simple: if it fits in 3 lines of Ruby, it belongs in a helper.
Incorrect (partial for a one-liner):
<%# app/views/shared/_status_badge.html.erb %>
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium
<%= status == 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' %>">
<%= status.titleize %>
</span>
<%# Usage — partial overhead for a single element %>
<%= render "shared/status_badge", status: user.status %>Correct (helper for a small fragment):
# app/helpers/status_helper.rb
module StatusHelper
STATUS_STYLES = {
"active" => "bg-green-100 text-green-700",
"inactive" => "bg-gray-100 text-gray-600",
"pending" => "bg-yellow-100 text-yellow-800",
"archived" => "bg-red-100 text-red-700"
}.freeze
def status_badge(status)
tag.span(
status.titleize,
class: "inline-flex items-center rounded-full px-2 py-1 text-xs font-medium #{STATUS_STYLES[status]}"
)
end
end<%# Usage — clean, no file overhead %>
<td><%= status_badge(user.status) %></td>When NOT to use this pattern: If the fragment grows beyond 3 lines of Ruby, contains nested HTML structure, or needs to yield a block of content, switch to a partial. Helpers that build complex multi-element HTML with content_tag nesting become unreadable fast. Also avoid helpers for elements that designers need to visually inspect -- partials are easier to locate and preview in Lookbook.
Reference: Action View Helpers -- Rails Guides
Use Partials by Default, Components When Earned
ERB partials handle 80% of view reuse in Rails with zero additional dependencies. Reaching for ViewComponent or Phlex before a partial has proven insufficient adds framework overhead, extra files, and a steeper learning curve for the team. Upgrade to a component only when a partial can no longer serve the need.
Incorrect (component for a simple, single-context card):
# app/components/project_card_component.rb
# Used in exactly ONE view — projects/index
class ProjectCardComponent < ViewComponent::Base
def initialize(project:)
@project = project
end
end<%# app/components/project_card_component.html.erb %>
<div class="rounded-lg border border-gray-200 p-4 shadow-sm">
<h3 class="text-lg font-semibold text-gray-900"><%= @project.name %></h3>
<p class="mt-1 text-sm text-gray-500"><%= truncate(@project.description, length: 120) %></p>
<span class="mt-2 inline-block text-xs text-gray-400"><%= time_ago_in_words(@project.updated_at) %> ago</span>
</div>Correct (start with a partial, upgrade when complexity demands it):
<%# app/views/projects/_card.html.erb — simple partial with explicit locals %>
<div class="rounded-lg border border-gray-200 p-4 shadow-sm">
<h3 class="text-lg font-semibold text-gray-900"><%= project.name %></h3>
<p class="mt-1 text-sm text-gray-500"><%= truncate(project.description, length: 120) %></p>
<span class="mt-2 inline-block text-xs text-gray-400"><%= time_ago_in_words(project.updated_at) %> ago</span>
</div>
<%# Usage — clean collection rendering %>
<%= render partial: "projects/card", collection: @projects, as: :project %># LATER: upgrade to component when you need slots for multiple content areas,
# isolated unit tests, or the card is used in 5+ unrelated views with variants.
# app/components/card_component.rb
class CardComponent < ViewComponent::Base
renders_one :header
renders_one :footer
renders_one :actions
def initialize(variant: :default)
@variant = variant
end
endWhen NOT to use this pattern: If the element requires isolated unit testing (e.g., complex conditional rendering based on permissions), multiple named content slots, or is a cross-cutting pattern used in 3+ unrelated contexts (dashboards, emails, PDFs), skip the partial phase and go directly to a component. Form builders are also better served by a dedicated ActionView::Helpers::FormBuilder subclass than by partials.
Reference: DHH on ViewComponent — Basecamp/Hey approach
Prove Patterns in Production Before Extraction
Speculative design system work produces components nobody uses and abstractions that fit imagined requirements instead of real ones. Build the feature with inline HTML first. Ship it. Let the pattern survive 2-3 sprints of real usage and appear in multiple places. Only then extract it into a shared partial or component. Rails itself was extracted from Basecamp after proving concepts in production -- apply the same discipline to your design system.
Incorrect (designing a modal library before any modal exists):
# app/components/modal_component.rb
# Sprint 1: Built before any feature needs a modal
class ModalComponent < ViewComponent::Base
renders_one :header
renders_one :body
renders_one :footer
def initialize(size: :md, dismissable: true, backdrop: :default, position: :center)
@size = size
@dismissable = dismissable
@backdrop = backdrop
@position = position
end
# 80 lines of logic for variants nobody has requested yet
endCorrect (inline first, extract after proven production use):
<%# Sprint 1: app/views/projects/show.html.erb — first modal, inline %>
<div data-controller="modal" class="relative z-50" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-gray-500/75"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="w-full max-w-lg rounded-xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-gray-900">Delete Project</h3>
<p class="mt-2 text-sm text-gray-500">This action cannot be undone.</p>
<div class="mt-4 flex justify-end gap-3">
<button data-action="modal#close" class="rounded-md px-3 py-2 text-sm text-gray-700">Cancel</button>
<%= button_to "Delete", project_path(@project), method: :delete,
class: "rounded-md bg-red-600 px-3 py-2 text-sm text-white" %>
</div>
</div>
</div>
</div>
<%# Sprint 3: Third modal appears in app/views/members/index.html.erb %>
<%# Pattern is proven — NOW extract to app/views/shared/_modal.html.erb %>
<%= render "shared/modal", title: "Remove Member" do %>
<p class="text-sm text-gray-500">Remove <%= member.name %> from this team?</p>
<% end %>When NOT to use this pattern: Infrastructure-level patterns like form builders, flash message rendering, and design tokens should be extracted upfront because they affect every page from the start. If you know from prior projects that you will need a certain pattern (e.g., you are rebuilding an existing app), you can extract early based on that proven history.
Reference: DHH — The Rails Doctrine: Extract patterns from proven production work
Extract After Three Uses, Not Before
Premature extraction is the most common design system mistake in Rails. Creating a partial, component, or helper before a pattern has proven itself leads to wrong abstractions that are harder to change than inline duplication. Wait until you see the same pattern in 3+ places before extracting -- the third occurrence reveals which parts truly vary and which are stable.
Incorrect (extracting after first use):
# app/components/button_component.rb
# Created after writing ONE button in ONE view
class ButtonComponent < ViewComponent::Base
def initialize(label:, variant: :primary, size: :md, icon: nil, disabled: false)
@label = label
@variant = variant
@size = size
@icon = icon
@disabled = disabled
end
end<%# app/components/button_component.html.erb %>
<button class="btn btn-<%= @variant %> btn-<%= @size %> <%= 'btn-disabled' if @disabled %>">
<%= render_icon(@icon) if @icon %>
<%= @label %>
</button>Correct (inline first, extract after three proven uses):
<%# app/views/projects/show.html.erb — first use, inline is fine %>
<button class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500">
Save Project
</button>
<%# app/views/tasks/new.html.erb — second use, still inline %>
<button class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500">
Create Task
</button>
<%# app/views/comments/_form.html.erb — third use: NOW extract %>
<%# Pattern is proven. Extract to app/views/shared/_button.html.erb %>
<%= render "shared/button", label: "Post Comment" %>When NOT to use this pattern: Design tokens and custom form builders are exceptions -- extract these upfront because they enforce consistency from day one. A FormBuilder that standardizes labels, error messages, and help text across every form is worth the early investment. Similarly, Tailwind @theme tokens for colors, spacing, and typography should be defined before any UI work begins.
Reference: Sandi Metz — The Wrong Abstraction
Generate Accessible Labels and ARIA Attributes Automatically
Accessibility should not be optional and it should not require extra work from developers. The FormBuilder should generate proper <label for="..."> tags, aria-describedby for help text, and aria-invalid for error states. This ensures every form field is accessible without developers thinking about it.
Incorrect (missing labels, no ARIA attributes, help text not linked to input):
<div class="mb-4">
<span class="text-sm text-gray-700">Email</span>
<input type="email" name="user[email]" class="block w-full rounded-md border-gray-300" />
<p class="text-gray-500 text-sm">We'll never share your email.</p>
</div>Correct (FormBuilder generates accessible markup automatically):
# app/form_builders/design_system_form_builder.rb
class DesignSystemFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
help_text = options.delete(:help)
has_errors = object&.errors&.[](method)&.any?
help_id = "#{object_name}_#{method}_help" if help_text
error_id = "#{object_name}_#{method}_error" if has_errors
described_by = [help_id, error_id].compact.join(" ").presence
aria_options = {}
aria_options["aria-describedby"] = described_by if described_by
aria_options["aria-invalid"] = true if has_errors
@template.content_tag(:div, class: "mb-4") do
label(method, class: "block text-sm font-medium text-gray-700") +
super(method, options.reverse_merge(class: field_classes(method)).merge(aria_options)) +
error_tag(method, error_id) +
help_tag(help_text, help_id)
end
end
private
def error_tag(method, error_id)
return "".html_safe unless object&.errors&.[](method)&.any?
@template.content_tag(:p, object.errors[method].first,
id: error_id,
class: "text-red-600 text-sm mt-1",
role: "alert"
)
end
def help_tag(text, help_id)
return "".html_safe unless text
@template.content_tag(:p, text, id: help_id, class: "text-gray-500 text-sm mt-1")
end
def field_classes(method)
has_errors = object&.errors&.[](method)&.any?
@template.class_names(
"mt-1 block w-full rounded-md shadow-sm",
"border-gray-300": !has_errors,
"border-red-500": has_errors
)
end
endThe rendered output for a field with help text and an error:
<div class="mb-4">
<label for="user_email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="text"
name="user[email]"
id="user_email"
class="mt-1 block w-full rounded-md shadow-sm border-red-500"
aria-describedby="user_email_help user_email_error"
aria-invalid="true" />
<p id="user_email_error" class="text-red-600 text-sm mt-1" role="alert">has already been taken</p>
<p id="user_email_help" class="text-gray-500 text-sm mt-1">We'll never share your email.</p>
</div>Key accessibility attributes generated automatically:
<label for="...">linked to the input via matchingidaria-describedbylinking the input to both help text and error messagesaria-invalid="true"on fields with validation errorsrole="alert"on error messages for screen reader announcement
Reference: WAI-ARIA Forms Practices
Standardize Submit Button Styles in FormBuilder
Submit buttons accumulate style drift faster than any other form element. One form uses bg-blue-600, another uses bg-indigo-500, a third uses inline styles. Overriding the submit method in your FormBuilder lets you define button variants once and use them consistently everywhere.
Incorrect (copying button classes into every form):
<%# app/views/users/new.html.erb %>
<%= f.submit "Create Account", class: "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 font-medium" %>
<%# app/views/settings/edit.html.erb — slightly different classes %>
<%= f.submit "Save Settings", class: "bg-blue-500 text-white px-6 py-2 rounded-md hover:bg-blue-600" %>
<%# app/views/admin/users/edit.html.erb — someone added inline styles %>
<%= f.submit "Delete User", class: "bg-red-500 text-white px-4 py-2 rounded", style: "font-weight: bold" %>Correct (submit method override with variant support):
# app/form_builders/design_system_form_builder.rb
class DesignSystemFormBuilder < ActionView::Helpers::FormBuilder
BUTTON_VARIANTS = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary: "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-blue-500",
destructive: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500"
}.freeze
BUTTON_BASE = "inline-flex items-center px-4 py-2 rounded-md font-medium text-sm " \
"focus:outline-none focus:ring-2 focus:ring-offset-2 " \
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
def submit(value = nil, options = {})
variant = options.delete(:variant) || :primary
variant_classes = BUTTON_VARIANTS.fetch(variant) do
raise ArgumentError, "Unknown button variant: #{variant}. Use: #{BUTTON_VARIANTS.keys.join(', ')}"
end
options[:class] = class_names(BUTTON_BASE, variant_classes, options[:class])
super(value, options)
end
end<%# Usage — clean, consistent, variant-based %>
<%= form_with model: @user do |f| %>
<%= f.text_field :name %>
<%= f.text_field :email %>
<div class="flex gap-3 mt-6">
<%= f.submit "Save Changes", variant: :primary %>
<%= f.submit "Cancel", variant: :secondary, formnovalidate: true %>
</div>
<% end %>
<%# Destructive action %>
<%= form_with model: @user, method: :delete do |f| %>
<%= f.submit "Delete Account", variant: :destructive,
data: { turbo_confirm: "Are you sure? This cannot be undone." } %>
<% end %>If you need button styles outside of forms (e.g., link-styled buttons), create a matching button_link_to helper in ApplicationHelper that uses the same BUTTON_VARIANTS constant:
# app/helpers/application_helper.rb
module ApplicationHelper
def button_link_to(text, url, variant: :primary, **options)
variant_classes = DesignSystemFormBuilder::BUTTON_VARIANTS.fetch(variant)
options[:class] = class_names(DesignSystemFormBuilder::BUTTON_BASE, variant_classes, options[:class])
link_to(text, url, options)
end
endReference: Rails FormBuilder API
Create a Custom FormBuilder for Design System Consistency
Every form in your app needs labels, error messages, help text, and consistent wrapper markup. Without a custom FormBuilder, developers copy-paste this structure into every form, leading to drift and inconsistency. Extending ActionView::Helpers::FormBuilder lets you define the structure once and enforce it everywhere.
Incorrect (manually duplicating form field structure in every view):
<%= form_with model: @user do |f| %>
<div class="mb-4">
<label for="user_name" class="block text-sm font-medium text-gray-700">Name</label>
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm" %>
<% if @user.errors[:name].any? %>
<p class="text-red-600 text-sm mt-1"><%= @user.errors[:name].first %></p>
<% end %>
</div>
<div class="mb-4">
<label for="user_email" class="block text-sm font-medium text-gray-700">Email</label>
<%= f.text_field :email, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm" %>
<% if @user.errors[:email].any? %>
<p class="text-red-600 text-sm mt-1"><%= @user.errors[:email].first %></p>
<% end %>
</div>
<% end %>Correct (custom FormBuilder that wraps fields with labels, errors, and help text automatically):
# app/form_builders/design_system_form_builder.rb
class DesignSystemFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
help_text = options.delete(:help)
field_wrapper(method, help_text) do
super(method, options.reverse_merge(
class: field_classes(method)
))
end
end
# Override other field types the same way:
# email_field, password_field, text_area, telephone_field, etc.
private
def field_wrapper(method, help_text = nil, &block)
help_id = "#{object_name}_#{method}_help" if help_text
@template.content_tag(:div, class: "mb-4") do
label(method, class: "block text-sm font-medium text-gray-700") +
block.call +
error_message(method) +
(help_text ? @template.content_tag(:p, help_text, id: help_id, class: "text-gray-500 text-sm mt-1") : "".html_safe)
end
end
def error_message(method)
return "".html_safe unless object&.errors&.[](method)&.any?
@template.content_tag(:p, object.errors[method].first, class: "text-red-600 text-sm mt-1")
end
def field_classes(method)
has_errors = object&.errors&.[](method)&.any?
@template.class_names(
"mt-1 block w-full rounded-md shadow-sm",
"border-gray-300 focus:border-blue-500 focus:ring-blue-500": !has_errors,
"border-red-500 focus:border-red-500 focus:ring-red-500": has_errors
)
end
end<%# Usage — clean, consistent, zero boilerplate %>
<%= form_with model: @user do |f| %>
<%= f.text_field :name %>
<%= f.text_field :email, help: "We'll never share your email." %>
<% end %>Reference: Rails Form Helpers Guide
Display Field Errors Inline Below Each Input
Users should see exactly which field has a problem without scanning a list at the top of the page. Inline errors below each field reduce cognitive load and speed up form correction. The FormBuilder should handle this automatically so developers never write error display logic manually.
Incorrect (only showing grouped errors at the top of the form):
<%= form_with model: @user do |f| %>
<% if @user.errors.any? %>
<div class="bg-red-50 border border-red-400 rounded p-4 mb-6">
<h3 class="text-red-800 font-medium"><%= pluralize(@user.errors.count, "error") %> prevented saving:</h3>
<ul class="list-disc ml-5 mt-2">
<% @user.errors.full_messages.each do |msg| %>
<li class="text-red-700"><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<%# User has to guess which field each error belongs to %>
<%= f.text_field :name, class: "block w-full rounded-md border-gray-300" %>
<%= f.text_field :email, class: "block w-full rounded-md border-gray-300" %>
<% end %>Correct (FormBuilder renders errors inline below each field automatically):
# app/form_builders/design_system_form_builder.rb
class DesignSystemFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
@template.content_tag(:div, class: "mb-4") do
label(method, class: "block text-sm font-medium text-gray-700") +
super(method, options.reverse_merge(class: field_classes(method))) +
inline_errors(method)
end
end
private
def inline_errors(method)
return "".html_safe unless object&.errors&.[](method)&.any?
@template.content_tag(:p, class: "text-red-600 text-sm mt-1") do
object.errors[method].first
end
end
def field_classes(method)
has_errors = object&.errors&.[](method)&.any?
@template.class_names(
"mt-1 block w-full rounded-md shadow-sm",
"border-gray-300 focus:border-blue-500 focus:ring-blue-500": !has_errors,
"border-red-500 focus:border-red-500 focus:ring-red-500": has_errors
)
end
endThe rendered HTML for a field with errors:
<div class="mb-4">
<label for="user_email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="text" name="user[email]" id="user_email"
class="mt-1 block w-full rounded-md shadow-sm border-red-500 focus:border-red-500 focus:ring-red-500" />
<p class="text-red-600 text-sm mt-1">has already been taken</p>
</div>You can still show a summary at the top for screen readers, but the inline error is the primary visual feedback.
Reference: Rails Active Record Validations
Use a form_group Method for Custom Field Layouts
Not every form field stands alone. Address fields (city, state, zip), name fields (first, last), and date ranges need to be grouped visually. Without a form_group helper, developers invent their own wrapper markup each time, producing inconsistent spacing and labeling. A form_group method gives you a consistent wrapper for multi-field layouts.
Incorrect (ad-hoc div wrappers with inconsistent spacing in every form):
<div style="margin-bottom: 16px;">
<span class="font-medium">Address</span>
<div class="flex gap-2">
<%= f.text_field :city, class: "w-1/2 rounded-md border-gray-300", placeholder: "City" %>
<%= f.text_field :state, class: "w-1/4 rounded-md border-gray-300", placeholder: "State" %>
<%= f.text_field :zip, class: "w-1/4 rounded-md border-gray-300", placeholder: "ZIP" %>
</div>
</div>
<%# Another form uses different markup for the same pattern %>
<div class="mb-6">
<label class="block mb-1 text-gray-600">Address</label>
<div class="grid grid-cols-4 gap-3">
<%= f.text_field :city, class: "col-span-2 ..." %>
...
</div>
</div>Correct (form_group method for consistent multi-field layouts):
# app/form_builders/design_system_form_builder.rb
class DesignSystemFormBuilder < ActionView::Helpers::FormBuilder
def form_group(legend = nil, options = {}, &block)
layout = options.delete(:layout) || :horizontal
help_text = options.delete(:help)
layout_class = case layout
when :horizontal then "flex gap-4"
when :grid then "grid grid-cols-12 gap-4"
else ""
end
@template.content_tag(:fieldset, class: "mb-6") do
legend_tag(legend) +
@template.content_tag(:div, class: layout_class, &block) +
(help_text ? @template.content_tag(:p, help_text, class: "text-gray-500 text-sm mt-1") : "".html_safe)
end
end
# Lightweight field method for use inside form_group (no wrapper div)
def inline_field(method, type: :text_field, **options)
wrapper_class = options.delete(:wrapper_class) || ""
@template.content_tag(:div, class: wrapper_class) do
label(method, class: "block text-sm font-medium text-gray-700") +
send(type, method, options.reverse_merge(class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm"))
end
end
private
def legend_tag(text)
return "".html_safe unless text
@template.content_tag(:legend, text, class: "block text-sm font-medium text-gray-900 mb-2")
end
end<%# Usage — consistent, readable, flexible %>
<%= form_with model: @user do |f| %>
<%= f.form_group "Full Name", layout: :horizontal do %>
<%= f.inline_field :first_name, wrapper_class: "flex-1" %>
<%= f.inline_field :last_name, wrapper_class: "flex-1" %>
<% end %>
<%= f.form_group "Address", layout: :horizontal do %>
<%= f.inline_field :city, wrapper_class: "flex-1" %>
<%= f.inline_field :state, wrapper_class: "w-24" %>
<%= f.inline_field :zip, wrapper_class: "w-32" %>
<% end %>
<% end %>The rendered output:
<fieldset class="mb-6">
<legend class="block text-sm font-medium text-gray-900 mb-2">Address</legend>
<div class="flex gap-4">
<div class="flex-1">
<label for="user_city" class="block text-sm font-medium text-gray-700">City</label>
<input type="text" name="user[city]" id="user_city" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm" />
</div>
<div class="w-24">
<label for="user_state" class="block text-sm font-medium text-gray-700">State</label>
<input type="text" name="user[state]" id="user_state" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm" />
</div>
<div class="w-32">
<label for="user_zip" class="block text-sm font-medium text-gray-700">Zip</label>
<input type="text" name="user[zip]" id="user_zip" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm" />
</div>
</div>
</fieldset>Reference: Rails Form Helpers Guide
Set Your Custom FormBuilder as the Application Default
If developers have to remember to pass builder: DesignSystemFormBuilder to every form_with call, someone will forget. Setting default_form_builder in ApplicationController makes every form use your custom builder automatically. No opt-in required, no inconsistency possible.
Incorrect (specifying the builder explicitly in every form):
<%# app/views/users/new.html.erb %>
<%= form_with model: @user, builder: DesignSystemFormBuilder do |f| %>
<%= f.text_field :name %>
<% end %>
<%# app/views/posts/new.html.erb — someone forgot the builder option %>
<%= form_with model: @post do |f| %>
<div class="mb-4">
<label for="post_title" class="block text-sm font-medium text-gray-700">Title</label>
<%= f.text_field :title, class: "mt-1 block w-full rounded-md border-gray-300" %>
</div>
<% end %>Correct (one-line configuration in ApplicationController):
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
default_form_builder DesignSystemFormBuilder
end<%# Every form_with now uses DesignSystemFormBuilder automatically %>
<%= form_with model: @user do |f| %>
<%= f.text_field :name %>
<%= f.text_field :email, help: "We'll never share your email." %>
<% end %>You can still override the builder for specific forms if needed (e.g., an admin-only form with different styling):
<%= form_with model: @setting, builder: AdminFormBuilder do |f| %>
<%= f.text_field :value %>
<% end %>Reference: ActionController default_form_builder
Build Status Helpers That Return Styled Fragments
When status-to-color mappings are scattered across views, a design change (e.g., "warning badges should be amber, not yellow") requires a find-and-replace across dozens of files. Centralizing these mappings in helpers means one change propagates everywhere. The helper owns the mapping from domain value to visual presentation.
Incorrect (conditionals and style mappings duplicated across views):
<%# app/views/orders/index.html.erb %>
<% @orders.each do |order| %>
<% if order.status == "pending" %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Pending</span>
<% elsif order.status == "shipped" %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Shipped</span>
<% elsif order.status == "delivered" %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Delivered</span>
<% elsif order.status == "cancelled" %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Cancelled</span>
<% end %>
<% end %>
<%# app/views/admin/orders/show.html.erb — same mapping, slightly different classes %>Correct (helper with a status map hash):
# app/helpers/orders_helper.rb
module OrdersHelper
ORDER_STATUS_STYLES = {
"pending" => { bg: "bg-yellow-100", text: "text-yellow-800", label: "Pending" },
"confirmed" => { bg: "bg-blue-100", text: "text-blue-800", label: "Confirmed" },
"shipped" => { bg: "bg-indigo-100", text: "text-indigo-800", label: "Shipped" },
"delivered" => { bg: "bg-green-100", text: "text-green-800", label: "Delivered" },
"cancelled" => { bg: "bg-red-100", text: "text-red-800", label: "Cancelled" },
"refunded" => { bg: "bg-gray-100", text: "text-gray-800", label: "Refunded" }
}.freeze
def order_status_badge(status)
config = ORDER_STATUS_STYLES.fetch(status.to_s) do
{ bg: "bg-gray-100", text: "text-gray-800", label: status.to_s.titleize }
end
tag.span(
config[:label],
class: class_names(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
config[:bg],
config[:text]
)
)
end
end<%# Usage — one call, always consistent %>
<%= order_status_badge(order.status) %>Apply the same pattern for other domain-specific badges:
# app/helpers/users_helper.rb
module UsersHelper
ROLE_STYLES = {
"admin" => { bg: "bg-purple-100", text: "text-purple-800" },
"editor" => { bg: "bg-blue-100", text: "text-blue-800" },
"viewer" => { bg: "bg-gray-100", text: "text-gray-800" }
}.freeze
def role_badge(user)
config = ROLE_STYLES.fetch(user.role, ROLE_STYLES["viewer"])
tag.span(
user.role.titleize,
class: class_names(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
config[:bg],
config[:text]
)
)
end
end# app/helpers/tasks_helper.rb
module TasksHelper
def priority_indicator(task)
color = case task.priority
when "critical" then "text-red-600"
when "high" then "text-orange-500"
when "medium" then "text-yellow-500"
when "low" then "text-gray-400"
end
tag.span(class: "inline-flex items-center gap-1 text-sm") do
icon("flag", size: :sm, class: color) +
tag.span(task.priority.titleize)
end
end
endReference: Rails Action View Helpers
Use class_names for Conditional CSS Classes
Ternary operators and string interpolation in class attributes produce unreadable, error-prone code. Rails 7+ provides class_names (aliased as token_list) for building conditional class strings cleanly. It accepts a hash where keys are class names and values are booleans.
Incorrect (ternary operators and string interpolation in class attributes):
<%# Messy string interpolation %>
<div class="btn <%= active ? 'btn-active' : '' %> <%= disabled ? 'opacity-50 cursor-not-allowed' : '' %> <%= size == :large ? 'px-6 py-3 text-lg' : 'px-4 py-2 text-sm' %>">
<%= label %>
</div>
<%# Even worse in a helper %>def nav_link(text, path)
active = current_page?(path)
classes = "nav-link"
classes += " nav-link-active" if active
classes += " text-gray-400" unless active
link_to text, path, class: classes
endCorrect (using class_names for clean conditional class logic):
<%# Clean, readable conditional classes in ERB %>
<div class="<%= class_names(
"btn",
"btn-active": active,
"opacity-50 cursor-not-allowed": disabled,
"px-6 py-3 text-lg": size == :large,
"px-4 py-2 text-sm": size != :large
) %>">
<%= label %>
</div>
<%# Even cleaner with tag helper %>
<%= tag.div label, class: class_names(
"btn",
"btn-active": active,
"opacity-50 cursor-not-allowed": disabled
) %># Clean helper using class_names
module ApplicationHelper
def nav_link(text, path)
active = current_page?(path)
link_to text, path, class: class_names(
"nav-link px-3 py-2 rounded-md text-sm font-medium",
"nav-link-active bg-gray-900 text-white": active,
"text-gray-300 hover:bg-gray-700 hover:text-white": !active
)
end
def flash_class(type)
class_names(
"px-4 py-3 rounded-md text-sm font-medium mb-4",
"bg-green-50 text-green-800 border border-green-200": type.to_sym == :notice,
"bg-red-50 text-red-800 border border-red-200": type.to_sym == :alert,
"bg-blue-50 text-blue-800 border border-blue-200": type.to_sym == :info
)
end
endclass_names handles nil values and blank strings gracefully -- falsy conditions are simply omitted from the output. It works in helpers, ERB templates, and inside custom FormBuilder methods.
# token_list is an alias for class_names
class_names("base", "active": true, "hidden": false)
# => "base active"
token_list("base", "active": true, "hidden": false)
# => "base active"Reference: Rails class_names helper
Create an Icon Helper for Consistent Icon Usage
Icons get copy-pasted as raw SVG markup across dozens of views, making it impossible to change icon sets or standardize sizing later. A single icon helper centralizes icon rendering. You can switch from inline SVG to a sprite sheet, or from Heroicons to Lucide, by changing one file.
Incorrect (copy-pasting SVG markup inline in every view):
<%# app/views/users/show.html.erb %>
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<%# app/views/orders/index.html.erb — same icon, different size %>
<svg class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<%# 48 more files with similar copy-pasted SVGs... %>Correct (centralized icon helper with consistent API):
# app/helpers/icon_helper.rb
module IconHelper
ICON_PATH = Rails.root.join("app/assets/images/icons")
def icon(name, size: :md, **options)
size_classes = icon_size_class(size)
options[:class] = class_names(size_classes, options[:class])
inline_svg(name, options)
end
private
def inline_svg(name, options)
file_path = ICON_PATH.join("#{name}.svg")
unless File.exist?(file_path)
Rails.logger.warn("Icon not found: #{name}")
return tag.span("?", class: options[:class], title: "Missing icon: #{name}")
end
svg = File.read(file_path)
# Inject class and aria attributes into the SVG tag
svg = svg.sub("<svg", %(<svg class="#{options[:class]}" aria-hidden="true"))
svg.html_safe
end
def icon_size_class(size)
case size
when :xs then "w-3 h-3"
when :sm then "w-4 h-4"
when :md then "w-5 h-5"
when :lg then "w-6 h-6"
when :xl then "w-8 h-8"
else size # Allow passing custom classes like "w-10 h-10"
end
end
end<%# Usage — clean, consistent, easy to change %>
<%= icon("check", size: :md, class: "text-green-600") %>
<%= icon("x-mark", size: :sm, class: "text-red-500") %>
<%= icon("arrow-right", size: :lg, class: "text-gray-400") %>
<%# In a button %>
<button class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md">
<%= icon("plus", size: :sm) %>
Add Item
</button>For production apps, consider using the inline_svg gem instead of the hand-rolled version above -- it handles caching, asset pipeline integration, and SVG optimization:
# Gemfile
gem "inline_svg"
# app/helpers/icon_helper.rb
module IconHelper
def icon(name, size: :md, **options)
options[:class] = class_names(icon_size_class(size), options[:class])
options[:aria_hidden] = true
inline_svg_tag("icons/#{name}.svg", **options)
end
endReference: Heroicons
Scope Helpers to Domain Modules, Not ApplicationHelper
ApplicationHelper is the junk drawer of Rails apps. Without discipline, it grows to 300+ lines covering users, orders, products, and settings. Domain-scoped helpers are easier to find, test, and maintain. Rails auto-includes the helper module matching the current controller, so UsersHelper methods are available in all user views automatically.
Incorrect (everything dumped into ApplicationHelper):
# app/helpers/application_helper.rb — 250 lines and growing
module ApplicationHelper
def user_avatar(user, size: :md) ... end
def role_badge(user) ... end
def user_initials(user) ... end
def order_status_badge(status) ... end
def order_total(order) ... end
def format_price(amount, currency: "USD") ... end
def product_availability(product) ... end
def product_image_url(product) ... end
def setting_label(key) ... end
def icon(name, **options) ... end
def flash_class(type) ... end
def page_title(title = nil) ... end
def time_ago_in_words_short(time) ... end
# ... 30 more methods
endCorrect (domain helpers in resource-specific modules):
# app/helpers/application_helper.rb — only truly global helpers (< 30 lines)
module ApplicationHelper
def icon(name, size: :md, **options) ... end
def flash_class(type) ... end
def page_title(title = nil)
if title
content_for(:page_title, title)
else
content_for?(:page_title) ? "#{content_for(:page_title)} | MyApp" : "MyApp"
end
end
end# app/helpers/users_helper.rb — user-specific presentation logic
module UsersHelper
def user_avatar(user, size: :md) ... end
def role_badge(user) ... end
def user_initials(user) ... end
end# app/helpers/orders_helper.rb — order-specific presentation logic
module OrdersHelper
def order_status_badge(status) ... end
def order_total(order) ... end
def format_price(amount, currency: "USD") ... end
end# app/helpers/products_helper.rb — product-specific presentation logic
module ProductsHelper
def product_availability(product) ... end
def product_image_url(product, variant: :thumb) ... end
endRails auto-includes helpers based on controller name. For OrdersController, OrdersHelper is available in all its views. If you need a helper from another module in a view, you have two options:
# Option 1: Include explicitly in the controller
class AdminDashboardController < ApplicationController
helper OrdersHelper
helper UsersHelper
end
# Option 2: Include all helpers (Rails default, but consider opting out)
# config/application.rb
config.action_controller.include_all_helpers = true # defaultA good rule of thumb: if a helper method references a specific model (User, Order, Product), it belongs in that model's helper module. If it is truly model-agnostic (icon rendering, flash styling, page titles), it belongs in ApplicationHelper.
Reference: Rails Action View Helpers
Use Tag Helpers for Small HTML Fragments
Building HTML strings with interpolation and calling html_safe is the most common source of XSS vulnerabilities in Rails helpers. The tag helper (tag.span, tag.div) and content_tag build HTML safely, handle attribute escaping, and produce cleaner code. Never concatenate HTML strings manually.
Incorrect (building HTML strings with interpolation and html_safe):
# app/helpers/application_helper.rb
module ApplicationHelper
def role_label(role)
"<span class='badge badge-#{role}'>#{role.titleize}</span>".html_safe
end
def status_dot(status)
color = status == "active" ? "green" : "gray"
"<span class='inline-block w-2 h-2 rounded-full bg-#{color}-500'></span> <span>#{status}</span>".html_safe
end
def external_link(url, text)
"<a href='#{url}' target='_blank' rel='noopener' class='text-blue-600 underline'>#{text}</a>".html_safe
end
endCorrect (using tag helpers for safe, readable HTML generation):
# app/helpers/application_helper.rb
module ApplicationHelper
def role_label(role)
tag.span(role.titleize, class: "badge badge-#{role}")
end
def status_dot(status)
color = status == "active" ? "green" : "gray"
tag.span(class: "inline-flex items-center gap-1.5") do
tag.span(class: "inline-block w-2 h-2 rounded-full bg-#{color}-500") +
tag.span(status.titleize)
end
end
def external_link(url, text)
tag.a(text, href: url, target: "_blank", rel: "noopener", class: "text-blue-600 underline")
end
endKey advantages of tag helpers:
- Automatic escaping -- user-provided values are escaped by default
- Block syntax -- nest elements with
do...endinstead of string concatenation - Hash-style attributes --
data: { controller: "tooltip" }generatesdata-controller="tooltip"
# Nesting with blocks
tag.div(class: "card", data: { controller: "expandable" }) do
tag.h3("Title", class: "card-title") +
tag.p("Body content", class: "card-body")
end
# Self-closing tags
tag.hr(class: "my-4")
tag.input(type: "hidden", name: "token", value: form_token)Reference: Rails Action View Tag Helpers
Audit Views Quarterly for Duplicated UI Patterns
Design systems drift. A developer copies a card layout into a new view, tweaks the spacing, and now you have two slightly different cards. Multiply this across a team over months and you get 15 variations of the same pattern. Quarterly audits catch this drift before it compounds into an unmaintainable mess.
Incorrect (duplicated patterns scattered across views, never audited):
<%# app/views/users/index.html.erb — copy-pasted card %>
<div class="bg-white rounded-lg shadow-md p-6 mb-4">
<h3 class="text-lg font-semibold text-gray-900"><%= user.name %></h3>
<p class="text-gray-600 mt-2"><%= user.bio %></p>
</div>
<%# app/views/posts/index.html.erb — slightly different card %>
<div class="bg-white rounded-lg shadow-md p-6 mb-4">
<h3 class="text-lg font-semibold text-gray-900"><%= post.title %></h3>
<p class="text-gray-600 mt-2"><%= post.excerpt %></p>
</div>
<%# 4 more files with the same card pattern, each with minor variations %>Correct (periodic audit identifies and extracts repeated patterns):
<%# Run a quarterly audit to find duplicated patterns, then extract: %>
<%# app/views/shared/_card.html.erb %>
<div class="bg-white rounded-lg shadow-md p-6 mb-4">
<% if local_assigns[:title] %>
<h3 class="text-lg font-semibold text-gray-900"><%= title %></h3>
<% end %>
<%= yield %>
</div>
<%# app/views/users/index.html.erb %>
<%= render "shared/card", title: user.name do %>
<p class="text-gray-600 mt-2"><%= user.bio %></p>
<% end %>How to find duplicated patterns:
# Find repeated Tailwind class combinations (likely copy-pasted components)
grep -rn "rounded-lg shadow-md p-" app/views/ | sort | head -20
# Find repeated structural patterns (card-like wrappers)
grep -rn "class=.*bg-white.*rounded.*shadow" app/views/ --include="*.erb" | wc -l
# Find duplicated button class strings
grep -rn "bg-blue-600 text-white" app/views/ --include="*.erb"
# Find inline styles (should be zero in a design system)
grep -rn 'style="' app/views/ --include="*.erb" | wc -l
# Find hardcoded color values outside of token files
grep -rn "text-\[#" app/views/ --include="*.erb"
grep -rn "bg-\[#" app/views/ --include="*.erb"
# Count how many files use each shared partial vs inline markup
echo "=== Shared partial usage ==="
for partial in app/views/shared/_*.html.erb; do
name=$(basename "$partial" | sed 's/^_//' | sed 's/\.html\.erb$//')
count=$(grep -rn "render.*shared/${name}\|render.*\"shared/${name}\"" app/views/ --include="*.erb" | wc -l)
echo " $name: $count usages"
doneExtraction threshold: If a pattern appears in 3 or more files, extract it.
# Before audit: same card pattern in 4 files
# app/views/users/index.html.erb
# app/views/posts/index.html.erb
# app/views/admin/dashboard.html.erb
# app/views/search/results.html.erb
# All contain:
<div class="bg-white rounded-lg shadow-md p-6 mb-4">
<h3 class="text-lg font-semibold text-gray-900"><%= item.title %></h3>
<p class="text-gray-600 mt-2"><%= item.description %></p>
</div><%# After audit: extracted to a shared partial %>
<%# app/views/shared/_card.html.erb %>
<div class="bg-white rounded-lg shadow-md p-6 mb-4">
<% if local_assigns[:title] %>
<h3 class="text-lg font-semibold text-gray-900"><%= title %></h3>
<% end %>
<%= yield %>
</div>
<%# Usage %>
<%= render "shared/card", title: item.title do %>
<p class="text-gray-600 mt-2"><%= item.description %></p>
<% end %>Track metrics over time:
| Metric | Q1 | Q2 | Q3 | Target |
|---|---|---|---|---|
| Shared partials in use | 4 | 7 | 10 | Growing |
| Files with inline styles | 12 | 5 | 0 | Zero |
| Duplicated button patterns | 8 | 3 | 1 | Under 3 |
| Hardcoded color values | 15 | 6 | 2 | Under 5 |
Add the audit to your team's quarterly maintenance checklist alongside dependency updates and performance reviews.
Reference: Rails Layouts and Rendering
Document Design System Boundaries in a Living ADR
Without explicit boundaries, a Rails design system either grows to absorb everything (becoming an over-engineered framework) or stays unclear enough that developers build around it. A short Architecture Decision Record (ADR) states what is in the design system, what is explicitly out of scope, and why. This prevents the same debates from recurring every quarter.
Incorrect (no documentation -- everyone has a different mental model):
Developer A: "Why don't we have a DatePicker component?"
Developer B: "I thought we were using ViewComponent for everything?"
Developer C: "Are we supposed to use the FormBuilder or write custom markup?"
Lead: "I'll answer these questions... again... for the fourth time..."Correct (a living ADR that answers boundary questions):
# ADR: Design System Scope and Boundaries
**Status:** Active
**Last updated:** 2024-11-15
**Authors:** Team consensus (sprint retro 2024-11-12)
## What We Use
| Layer | Tool | Count |
|-------|------|-------|
| Form fields | `DesignSystemFormBuilder` | 1 builder, 8 field types |
| Shared partials | `app/views/shared/` | 12 partials |
| Stimulus controllers | `app/javascript/controllers/` | 6 custom + 4 from stimulus-components |
| View helpers | Resource-scoped helpers | 5 helper modules |
| Design tokens | Tailwind theme config | Colors, spacing, typography |
## What We Explicitly Do NOT Use
| Decision | Why |
|----------|-----|
| ViewComponent gem | Partials + helpers are sufficient at our scale (~40 views). Revisit at 100+ views. |
| Storybook / Lookbook | Not worth the maintenance cost for < 15 shared components. |
| Design token pipeline (Style Dictionary) | Tailwind config is our single source of truth. No need for a build step. |
| Component library gem | We are not building a reusable library. This is app-specific. |
| Custom CSS framework | Tailwind covers our needs. Custom CSS is limited to tokens and overrides. |
## When to Revisit
- **Add ViewComponent** when shared partials exceed 20 or when we need preview/testing isolation
- **Add Lookbook** when the team exceeds 5 frontend contributors
- **Add token pipeline** when we need to share tokens between Rails and a mobile app
## Principles
1. Extract after duplication, not before
2. Prefer Rails conventions over custom abstractions
3. Every addition must reduce total code, not add a new layer
4. If you can solve it with a partial + helper, do that firstKeep this document in one of these locations:
docs/adr/design-system-scope.md(if you use ADR format)ARCHITECTURE.mdin the project root (if you prefer a single file)- A wiki page linked from the README (if your team uses a wiki)
Update the ADR when:
- A new tool is adopted (e.g., adding ViewComponent)
- A pattern is deprecated (e.g., moving from partials to components)
- The team size or codebase size crosses a threshold listed in "When to Revisit"
The document should stay under 30 lines of content (excluding the table). If it needs more, the design system might be over-scoped.
Reference: Architectural Decision Records
Organize Design System Files in Predictable Locations
When design system files are scattered across custom directories (app/lib/ui/, app/design_system/, lib/components/), new developers spend time asking "where does this go?" instead of building features. Use standard Rails directories. The convention already exists -- follow it.
Incorrect (custom, non-standard directories):
app/
lib/
ui/
button.rb # Non-standard location
form_builder.rb
design_system/
components/ # Custom top-level directory
card.html.erb
assets/
stylesheets/
components/
button.css
lib/
components/ # Components in lib/ — not autoloaded by default
avatar.rb
frontend/
controllers/ # Stimulus controllers outside app/javascript/
toggle.jsCorrect (standard Rails directories for all design system files):
app/
assets/
stylesheets/
application.css # Main entry point
tokens/
_colors.css # Design tokens
_spacing.css
_typography.css
components/ # ViewComponent classes + templates
application_component.rb # Base class
avatar_component.rb
avatar_component.html.erb
button_component.rb
button_component.html.erb
card_component.rb
card_component.html.erb
modal_component.rb
modal_component.html.erb
form_builders/
design_system_form_builder.rb # Custom FormBuilder
helpers/
application_helper.rb # Global helpers (icon, flash_class)
users_helper.rb # Domain-specific helpers
orders_helper.rb
javascript/
controllers/
application.js # Stimulus application setup
index.js # Controller registration
toggle_controller.js # Behavior-named controllers
clipboard_controller.js
dropdown_controller.js
autosave_controller.js
views/
layouts/
application.html.erb # Main layout
_head.html.erb # Layout partials
_navbar.html.erb
_footer.html.erb
shared/ # Cross-cutting view partials
_flash.html.erb
_pagination.html.erb
_empty_state.html.erb
_search_form.html.erbKey principles:
- `app/components/` for ViewComponent classes (auto-loaded by Rails if using the viewcomponent gem)
- `app/views/shared/` for cross-cutting partials used by multiple controllers
- `app/javascript/controllers/` for all Stimulus controllers (auto-loaded by stimulus-rails)
- `app/form_builders/` for custom FormBuilder classes (auto-loaded by Rails)
- `app/helpers/` for view helper modules (auto-loaded by Rails)
- `app/assets/stylesheets/tokens/` for design token files
Do not create directories like app/ui/, app/design_system/, or frontend/. Every file in the list above is auto-loaded by Rails without additional configuration. Custom directories require explicit autoload path configuration and confuse developers who expect standard Rails structure.
Asset Pipeline: Propshaft (Rails 8+)
Rails 8 uses Propshaft as the default asset pipeline (replacing Sprockets). Propshaft serves assets from app/assets/ with digest-stamped filenames but does not compile or transform files. CSS is processed by Tailwind CLI or the tailwindcss-rails gem, not by Propshaft. Keep your CSS entry point at app/assets/stylesheets/application.css and let Tailwind handle the build.
Reference: Rails Directory Structure
Use Import Maps for Zero-Build JavaScript Delivery
Rails 7+ ships with Import Maps as the default JavaScript delivery mechanism. Import Maps let the browser resolve module imports directly from URLs — no npm, no node_modules, no Webpack, no esbuild. Pin your dependencies in config/importmap.rb and the browser handles the rest. This is the Rails Way for JavaScript in design system work.
Incorrect (installing npm, adding a bundler, configuring build pipelines):
// package.json — unnecessary in a Rails 7+ app
{
"dependencies": {
"@hotwired/stimulus": "^3.2.0",
"@hotwired/turbo-rails": "^8.0.0",
"@stimulus-components/clipboard": "^5.0.0"
},
"devDependencies": {
"esbuild": "^0.20.0"
},
"scripts": {
"build": "esbuild app/javascript/*.* --bundle --outdir=app/assets/builds"
}
}Correct (Import Maps — pins in Ruby, no build step):
# config/importmap.rb
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
# Pin all Stimulus controllers from app/javascript/controllers
pin_all_from "app/javascript/controllers", under: "controllers"
# Third-party Stimulus components
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js"
pin "sortablejs", to: "https://ga.jspm.io/npm:sortablejs@1.15.3/modular/sortable.esm.js"// app/javascript/application.js
import "@hotwired/turbo-rails"
import "controllers"// app/javascript/controllers/index.js
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)Adding Third-Party JS Packages
# Pin from CDN (jspm, unpkg, or jsdelivr)
bin/importmap pin @stimulus-components/clipboard
# Vendor locally for offline/CI reliability
bin/importmap pin @stimulus-components/clipboard --downloadVendored files are saved to vendor/javascript/ and served by Propshaft alongside your own JavaScript.
When Import Maps Don't Fit
Import Maps work for the vast majority of Rails apps. Switch to a bundler (jsbundling-rails with esbuild) only if you need:
- TypeScript compilation
- JSX/React components
- Tree-shaking for large dependency trees
- CSS Modules or CSS-in-JS
For standard Rails apps with Turbo, Stimulus, and a few third-party controllers, Import Maps are the simpler and faster choice.
Reference: Import Maps for Rails
Follow Consistent Naming Across All Design System Files
Without naming conventions, every developer invents their own: ButtonUI, button-component, ButtonWidget, Btn. This creates confusion, slows onboarding, and makes searching the codebase harder. Establish a naming convention once and enforce it in code review.
Incorrect (inconsistent naming across the same codebase):
app/components/ButtonUI.rb # no Component suffix
app/components/card_component.rb # snake_case filename, good
app/components/UserAvatar.rb # PascalCase filename, no suffix
app/views/shared/_btn.html.erb # abbreviated name
app/views/shared/_Card.html.erb # PascalCase partial (Rails expects lowercase)
app/javascript/controllers/settings_page_toggle_controller.js # page-specific name
app/helpers/ui_helper.rb # generic name — what UI?Correct (consistent naming convention across all design system files):
| Type | Convention | Example |
|---|---|---|
| ViewComponent class | {Name}Component | ButtonComponent, AvatarComponent |
| ViewComponent file | {name}_component.rb | button_component.rb |
| ViewComponent template | {name}_component.html.erb | button_component.html.erb |
| Shared partial | _{noun}.html.erb (lowercase) | _flash.html.erb, _pagination.html.erb |
| Stimulus controller | {behavior}_controller.js | toggle_controller.js, clipboard_controller.js |
| Helper module | {Resource}Helper | UsersHelper, OrdersHelper |
| FormBuilder | {Purpose}FormBuilder | DesignSystemFormBuilder |
| CSS / Token files | _{category}.css | _colors.css, _typography.css |
# Correct directory structure with consistent naming
app/
components/
avatar_component.rb
avatar_component.html.erb
button_component.rb
button_component.html.erb
card_component.rb
card_component.html.erb
form_builders/
design_system_form_builder.rb
helpers/
application_helper.rb # icon, flash_class, page_title only
users_helper.rb # user_avatar, role_badge
orders_helper.rb # order_status_badge, format_price
javascript/
controllers/
toggle_controller.js # behavior-named
clipboard_controller.js # behavior-named
dropdown_controller.js # behavior-named
autosave_controller.js # behavior-named
views/
shared/
_flash.html.erb
_pagination.html.erb
_empty_state.html.erbDocument the convention in a short section of your README or design system ADR so new developers can look it up:
## Naming Conventions
- Components: `{Name}Component` class in `app/components/{name}_component.rb`
- Partials: `_` prefix, lowercase, singular noun: `_flash.html.erb`
- Stimulus: behavior name + `_controller.js`: `toggle_controller.js`
- Helpers: resource name + `Helper`: `OrdersHelper`Reference: Rails Naming Conventions
Add Lookbook Previews for Every Shared Component
Every component in app/components/ should have a preview class in test/components/previews/. Lookbook renders these previews in a browser UI, serving as living documentation that stays in sync with the actual implementation. Without previews, components become invisible to designers and other developers, leading to duplicate implementations and design drift.
Incorrect (component without preview):
# app/components/alert_component.rb exists
# No preview — team cannot discover or visually verify it
# Result: another developer creates NotificationBannerComponent
# with nearly identical markup because they didn't know AlertComponent existedCorrect (preview with multiple scenarios):
# test/components/previews/alert_component_preview.rb
class AlertComponentPreview < ViewComponent::Preview
# @label Default Info Alert
def default
render(AlertComponent.new(
type: :info,
message: "Your profile has been updated successfully."
))
end
# @label Warning Alert
def warning
render(AlertComponent.new(
type: :warning,
message: "Your subscription expires in 3 days."
))
end
# @label Error Alert with Action
def error_with_action
render(AlertComponent.new(
type: :error,
message: "Payment failed. Please update your billing information.",
dismissable: true
)) do |alert|
alert.with_action do
helpers.link_to "Update billing", "/billing", class: "alert-link"
end
end
end
# @label Success Alert (Dismissable)
def success_dismissable
render(AlertComponent.new(
type: :success,
message: "Order #1234 has been shipped!",
dismissable: true
))
end
endPreview with Dynamic Parameters
Lookbook supports dynamic params for interactive exploration:
# test/components/previews/button_component_preview.rb
class ButtonComponentPreview < ViewComponent::Preview
# @param label text "Click me"
# @param variant select { choices: [primary, secondary, danger, ghost] }
# @param size select { choices: [sm, md, lg] }
# @param disabled toggle
def playground(label: "Click me", variant: :primary, size: :md, disabled: false)
render(ButtonComponent.new(
variant: variant.to_sym,
size: size.to_sym,
disabled: disabled
)) { label }
end
endSetup
# Gemfile
group :development do
gem "lookbook"
end# config/routes.rb (development only)
if Rails.env.development?
mount Lookbook::Engine, at: "/lookbook"
endNavigate to http://localhost:3000/lookbook to browse all component previews. Previews update in real-time as you modify component code.
Minimum Preview Coverage
Every shared component should have at minimum: 1. Default scenario showing the most common usage 2. Edge case scenario (empty content, long text, missing optional data) 3. Variant scenarios for each visual variant (if applicable)
Reference: Lookbook
Use Collection Rendering with Spacer Templates
Rails collection rendering supports spacer templates for separators between items. Use this instead of manual each_with_index loops that check for the last element. Collection rendering also provides automatic counter variables, handles empty collections gracefully, and is measurably faster than manual iteration because Rails optimizes the render path.
Incorrect (manual loop with separator logic):
<%# app/views/notifications/index.html.erb %>
<div class="notification-list">
<% @notifications.each_with_index do |notification, index| %>
<div class="notification">
<p class="notification-title"><%= notification.title %></p>
<p class="notification-body"><%= notification.body %></p>
<time><%= time_ago_in_words(notification.created_at) %> ago</time>
</div>
<% unless index == @notifications.length - 1 %>
<hr class="notification-divider">
<% end %>
<% end %>
</div>Correct (collection rendering with spacer template):
<%# app/views/notifications/_notification.html.erb %>
<%# locals: (notification:) %>
<div class="notification">
<p class="notification-title"><%= notification.title %></p>
<p class="notification-body"><%= notification.body %></p>
<time><%= time_ago_in_words(notification.created_at) %> ago</time>
</div>
<%# app/views/notifications/_notification_divider.html.erb %>
<hr class="notification-divider">
<%# app/views/notifications/index.html.erb %>
<div class="notification-list">
<%= render partial: "notification",
collection: @notifications,
spacer_template: "notification_divider" %>
</div>Built-in Counter Variable
Collection rendering automatically provides a counter variable named {partial_name}_counter:
<%# app/views/steps/_step.html.erb %>
<%# locals: (step:) %>
<div class="step">
<span class="step-number"><%= step_counter %></span>
<h4><%= step.title %></h4>
<p><%= step.description %></p>
</div>When CSS Is the Better Separator
If the separator is purely visual (a line, spacing, or border), prefer CSS over a spacer template. Spacer templates are best for semantic separators that carry meaning or complex markup.
<%# CSS gap approach — no spacer template needed %>
<div class="notification-list divide-y divide-gray-200">
<%= render partial: "notification", collection: @notifications %>
</div>Use spacer templates when the separator contains interactive elements, conditional logic, or semantic HTML (like <hr> with an ARIA role).
Reference: Rails Collection Rendering
Always Pass Explicit Locals to Partials
Never rely on instance variables (@user) inside partials. Pass everything as local variables so the partial's contract is explicit and it can be reused across different controllers without hidden coupling. When a partial silently depends on an instance variable, renaming or removing that variable in one controller breaks rendering in another with no compile-time warning.
Incorrect (partial relies on instance variable):
<%# app/views/users/_user_card.html.erb %>
<div class="user-card">
<h3><%= @user.display_name %></h3>
<p><%= @user.email %></p>
</div>
<%# app/views/users/show.html.erb %>
<%= render "user_card" %>Correct (explicit local variables):
<%# app/views/users/_user_card.html.erb %>
<%# locals: (user:) %>
<div class="user-card">
<h3><%= user.display_name %></h3>
<p><%= user.email %></p>
</div>
<%# app/views/users/show.html.erb %>
<%= render "user_card", user: @user %>Rails 7.1+ Strict Locals
The locals: strict comment enforces the contract at render time. If a caller omits a required local, Rails raises an ActionView::Template::Error immediately rather than failing with a confusing nil reference deep in the template.
<%# locals: (user:, show_actions: true) %>
<div class="user-card">
<h3><%= user.display_name %></h3>
<% if show_actions %>
<%= link_to "Edit", edit_user_path(user), class: "btn btn-sm" %>
<% end %>
</div>Optional locals use Ruby's default argument syntax. This gives callers a clear API: required arguments must be provided, optional ones have sensible defaults.
Reference: Rails Strict Locals
Name Partials by What They Render, Not Where They Live
Name partials after the UI element they render (_card, _row, _header), prefixed with the domain object when disambiguation is needed (_user_card). Avoid naming partials after the page they appear on (_homepage_hero) unless the content is truly page-specific and will never be reused. Page-named partials lead to duplication when the same UI appears on a second page.
Incorrect (named by page location):
app/views/dashboards/_dashboard_user_section.html.erb
app/views/settings/_settings_user_info.html.erb
app/views/admin/_admin_user_details.html.erbAll three render nearly identical user information but have different names, making it impossible to know they overlap without reading each file.
Correct (named by what they render):
app/views/users/_card.html.erb # user card used on dashboard, settings, admin
app/views/users/_row.html.erb # user table row used in lists
app/views/shared/_page_header.html.erb # page header used across all sections<%# app/views/dashboards/show.html.erb %>
<%= render "shared/page_header", title: "Dashboard" %>
<%= render partial: "users/card", collection: @recent_users, as: :user %>
<%# app/views/settings/show.html.erb %>
<%= render "shared/page_header", title: "Settings" %>
<%= render "users/card", user: current_user %>Naming Convention Table
| Partial scope | Directory | Example |
|---|---|---|
| Belongs to a single resource | app/views/{resource}/ | app/views/users/_card.html.erb |
| Shared UI elements | app/views/shared/ | app/views/shared/_flash_messages.html.erb |
| Layout fragments | app/views/application/ | app/views/application/_navbar.html.erb |
| Page-specific (rare) | app/views/{controller}/ | app/views/dashboards/_metric_grid.html.erb |
Naming Patterns
_card.html.erb # a self-contained card display
_row.html.erb # a table or list row
_form.html.erb # a form for the resource (Rails convention)
_header.html.erb # a header section
_filters.html.erb # filter controls
_empty_state.html.erb # what to show when collection is emptyReference: Rails View Rendering Guide
Use Presenter Objects When Helpers Call Helpers
When helper methods start calling other helper methods, extract a presenter. A presenter is a plain Ruby object that wraps a model and provides display-specific logic. Presenters are testable without rendering views, have clear ownership (one presenter per model concern), and eliminate the flat namespace collisions that plague helper modules.
Incorrect (helper sprawl):
# app/helpers/users_helper.rb
module UsersHelper
def user_display_name(user)
user.preferred_name.presence || "#{user.first_name} #{user.last_name}"
end
def user_avatar_url(user)
user.avatar.attached? ? url_for(user.avatar) : asset_path("default_avatar.png")
end
def user_role_badge(user)
content_tag(:span, user_role_label(user), class: "badge badge-#{user_role_color(user)}")
end
def user_role_label(user)
user.admin? ? "Administrator" : user.role.titleize
end
def user_role_color(user)
user.admin? ? "red" : "gray"
end
endCorrect (presenter object):
# app/presenters/user_presenter.rb
class UserPresenter
attr_reader :user
delegate :email, :created_at, to: :user
def initialize(user)
@user = user
end
def display_name
user.preferred_name.presence || "#{user.first_name} #{user.last_name}"
end
def avatar_url
if user.avatar.attached?
Rails.application.routes.url_helpers.url_for(user.avatar)
else
"default_avatar.png"
end
end
def role_badge_class
user.admin? ? "badge-red" : "badge-gray"
end
def role_label
user.admin? ? "Administrator" : user.role.titleize
end
end<%# app/views/users/_user_card.html.erb %>
<%# locals: (user:) %>
<% presenter = UserPresenter.new(user) %>
<div class="user-card">
<img src="<%= presenter.avatar_url %>" alt="<%= presenter.display_name %>">
<h3><%= presenter.display_name %></h3>
<span class="badge <%= presenter.role_badge_class %>"><%= presenter.role_label %></span>
</div>Testing Presenters
# test/presenters/user_presenter_test.rb
class UserPresenterTest < ActiveSupport::TestCase
test "display_name prefers preferred_name" do
user = build(:user, preferred_name: "Pete", first_name: "Peter", last_name: "Smith")
presenter = UserPresenter.new(user)
assert_equal "Pete", presenter.display_name
end
test "display_name falls back to full name" do
user = build(:user, preferred_name: nil, first_name: "Peter", last_name: "Smith")
presenter = UserPresenter.new(user)
assert_equal "Peter Smith", presenter.display_name
end
endReference: DHH on View Patterns at Basecamp
Put Cross-Cutting Partials in app/views/shared
Partials used across multiple resource views belong in app/views/shared/ or app/views/application/. Resource-specific partials stay in their resource directory. This convention prevents the "where does this partial live?" hunt that slows down teams working across features. When a partial lives in users/ but is rendered from orders/, products/, and admin/, its location is misleading.
Incorrect (cross-cutting partial in resource directory):
app/views/users/_flash_messages.html.erb # used by orders, products, admin
app/views/orders/_pagination.html.erb # used by users, products, reports
app/views/products/_empty_state.html.erb # used everywhere<%# Confusing cross-directory render calls %>
<%= render "users/flash_messages" %> <%# Why users? This is the orders page %>
<%= render "orders/pagination" %> <%# Why orders? This is the products page %>Correct (shared partials in shared directory):
app/views/shared/_flash_messages.html.erb
app/views/shared/_pagination.html.erb
app/views/shared/_empty_state.html.erb<%# Clear and predictable render calls %>
<%= render "shared/flash_messages" %>
<%= render "shared/pagination", pagy: @pagy %>
<%= render "shared/empty_state",
icon: "inbox",
message: "No orders yet",
action_path: new_order_path,
action_label: "Create your first order" %>Convention Table
| Directory | Purpose | Examples |
|---|---|---|
app/views/{resource}/ | Partials specific to one resource | users/_card.html.erb, users/_form.html.erb |
app/views/shared/ | UI elements used across resources | shared/_flash_messages.html.erb, shared/_empty_state.html.erb |
app/views/application/ | Layout-level fragments | application/_navbar.html.erb, application/_footer.html.erb |
app/views/layouts/ | Full layout templates | layouts/application.html.erb, layouts/admin.html.erb |
Migration Heuristic
Move a partial to shared/ when it is rendered from 2+ different resource directories. A quick check:
# Find partials rendered from outside their own directory
grep -r "render.*users/" app/views --include="*.erb" | grep -v "app/views/users/"If the output shows other views rendering users/ partials, those partials are candidates for shared/.
Reference: Rails View Rendering Guide
Use yield Blocks for Partial Customization Points
Pass blocks to partials for customizable regions. This gives partial-level "slots" without needing ViewComponent or Phlex. When a partial needs to render caller-provided markup in specific places, render layout: with yield is the idiomatic Rails approach. This prevents creating multiple near-identical partial variants that differ only in one section.
Incorrect (multiple partial variants):
<%# app/views/shared/_card_with_footer.html.erb %>
<div class="card">
<div class="card-body"><%= body %></div>
<div class="card-footer">
<%= link_to "Save", "#", class: "btn btn-primary" %>
</div>
</div>
<%# app/views/shared/_card_with_delete_footer.html.erb %>
<div class="card">
<div class="card-body"><%= body %></div>
<div class="card-footer">
<%= button_to "Delete", "#", method: :delete, class: "btn btn-danger" %>
</div>
</div>Correct (yield block for customizable footer):
<%# app/views/shared/_card.html.erb %>
<%# locals: (title:) %>
<div class="card">
<div class="card-header">
<h3 class="card-title"><%= title %></h3>
</div>
<div class="card-body">
<%= yield %>
</div>
</div><%# Usage in a view %>
<%= render layout: "shared/card", locals: { title: "User Profile" } do %>
<p><%= @user.display_name %></p>
<p><%= @user.email %></p>
<% end %>Multiple Yield Points with Content For
For partials that need more than one customizable region, combine yield with content_for:
<%# app/views/shared/_modal.html.erb %>
<%# locals: (title:, size: "md") %>
<div class="modal modal-<%= size %>">
<div class="modal-header">
<h2><%= title %></h2>
</div>
<div class="modal-body">
<%= yield %>
</div>
<% if content_for?(:modal_footer) %>
<div class="modal-footer">
<%= yield :modal_footer %>
</div>
<% end %>
</div><%# Usage with both body and footer %>
<% content_for :modal_footer do %>
<%= button_tag "Cancel", class: "btn btn-secondary", data: { action: "modal#close" } %>
<%= button_tag "Confirm", class: "btn btn-primary", data: { action: "modal#confirm" } %>
<% end %>
<%= render layout: "shared/modal", locals: { title: "Confirm Deletion" } do %>
<p>Are you sure you want to delete this item? This action cannot be undone.</p>
<% end %>When yield blocks grow to need 3+ customizable regions, that is a signal to graduate to a ViewComponent with named slots.
Reference: Rails Partial Layouts
Compose Multiple Controllers on One Element
Stimulus's core architectural feature is that multiple controllers can coexist on a single DOM element. Instead of building a monolithic controller that handles everything, compose small, focused controllers that each handle one responsibility. This is Stimulus's equivalent of the single-responsibility principle.
Incorrect (one monolithic controller doing everything):
// app/javascript/controllers/mega_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
// Handles: validation, autosave, character count, password strength,
// dirty tracking, and keyboard shortcuts — all in one file
static targets = ["input", "count", "strengthMeter", "saveIndicator", "form"]
validate() { /* ... */ }
autosave() { /* ... */ }
updateCharCount() { /* ... */ }
checkPasswordStrength() { /* ... */ }
trackDirtyState() { /* ... */ }
handleKeyboard(event) { /* ... */ }
}<form data-controller="mega-form">
<%# All behavior locked into one controller %>
</form>Correct (multiple small controllers composed on the same element):
// app/javascript/controllers/autosave_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 1000 }
}
static targets = ["indicator"]
save() {
clearTimeout(this.timer)
this.timer = setTimeout(() => this.persist(), this.delayValue)
}
async persist() {
this.indicatorTarget.textContent = "Saving..."
await fetch(this.urlValue, {
method: "PATCH",
body: new FormData(this.element),
headers: { "X-CSRF-Token": document.querySelector("[name='csrf-token']").content }
})
this.indicatorTarget.textContent = "Saved"
}
}// app/javascript/controllers/dirty_tracker_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["field"]
connect() {
this.initialValues = this.fieldTargets.map(f => f.value)
}
check() {
const isDirty = this.fieldTargets.some((f, i) => f.value !== this.initialValues[i])
this.element.dataset.dirty = isDirty
window.onbeforeunload = isDirty ? () => "Unsaved changes" : null
}
reset() {
this.initialValues = this.fieldTargets.map(f => f.value)
this.check()
}
}// app/javascript/controllers/keyboard_submit_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
submit(event) {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault()
this.element.requestSubmit()
}
}
}<%# Three independent controllers on one form — each handles one concern %>
<%= form_with model: @post, data: {
controller: "autosave dirty-tracker keyboard-submit",
autosave_url_value: autosave_post_path(@post),
autosave_delay_value: 2000,
action: "keydown->keyboard-submit#submit"
} do |f| %>
<%= f.text_field :title, data: {
dirty_tracker_target: "field",
action: "input->autosave#save input->dirty-tracker#check"
} %>
<%= f.text_area :body, data: {
dirty_tracker_target: "field",
action: "input->autosave#save input->dirty-tracker#check"
} %>
<span data-autosave-target="indicator" class="text-sm text-gray-500"></span>
<%= f.submit "Publish" %>
<% end %>Each controller can be:
- Tested independently
- Reused on different forms
- Removed without affecting the others
- Understood in isolation
Reference: Stimulus Handbook - Installing Stimulus
Configure Controllers via Data Attributes, Not Hardcoded Values
Hardcoding CSS classes, URLs, or thresholds in Stimulus controllers makes them single-purpose. The Stimulus Values and Classes APIs let you configure behavior from HTML, keeping the JavaScript generic and the HTML contextual. One controller, many configurations.
Incorrect (hardcoded values in the controller):
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
toggle() {
// Hardcoded class — can't change without editing JS
this.menuTarget.classList.toggle("hidden")
}
select(event) {
// Hardcoded URL — only works for one endpoint
fetch("/api/users/search", {
method: "POST",
body: JSON.stringify({ query: event.target.value })
})
}
connect() {
// Hardcoded delay — can't adjust per-usage
this.timeout = setTimeout(() => this.close(), 5000)
}
}Correct (configurable via Stimulus Values and Classes APIs):
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu", "button"]
static classes = ["toggle", "active"]
static values = {
url: String, // Required string value
delay: { type: Number, default: 300 }, // Optional with default
closeOnSelect: { type: Boolean, default: true },
placement: { type: String, default: "bottom" }
}
toggle() {
const toggleClass = this.hasToggleClass ? this.toggleClass : "hidden"
this.menuTarget.classList.toggle(toggleClass)
if (this.hasActiveClass) {
this.buttonTarget.classList.toggle(this.activeClass)
}
}
async search(event) {
const response = await fetch(this.urlValue, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: event.target.value })
})
// ...
}
connect() {
this.autoCloseTimer = setTimeout(() => this.close(), this.delayValue)
}
disconnect() {
clearTimeout(this.autoCloseTimer)
}
}<%# User search dropdown — searches users API %>
<div data-controller="dropdown"
data-dropdown-url-value="/api/users/search"
data-dropdown-delay-value="500"
data-dropdown-toggle-class="hidden"
data-dropdown-active-class="bg-gray-100">
<button data-dropdown-target="button"
data-action="dropdown#toggle">
Search Users
</button>
<div data-dropdown-target="menu" class="hidden">...</div>
</div>
<%# Product filter dropdown — different URL, different delay %>
<div data-controller="dropdown"
data-dropdown-url-value="/api/products/filter"
data-dropdown-delay-value="200"
data-dropdown-close-on-select-value="false"
data-dropdown-toggle-class="opacity-0 scale-95">
<button data-dropdown-target="button"
data-action="dropdown#toggle">
Filter Products
</button>
<div data-dropdown-target="menu" class="opacity-0 scale-95 transition-all">...</div>
</div>The Values API provides automatic type coercion, default values, and change callbacks:
static values = {
count: { type: Number, default: 0 },
label: { type: String, default: "items" }
}
// Stimulus auto-generates a change callback
countValueChanged(newCount, oldCount) {
this.element.textContent = `${newCount} ${this.labelValue}`
}Reference: Stimulus Values
Write General-Purpose Controllers, Not Page-Specific Ones
A controller named dashboard-sidebar can only be used on the dashboard sidebar. A controller named toggle can be used anywhere you need to show/hide content. Name controllers by behavior, not by the page they were first written for. This is the difference between 5 reusable controllers and 50 one-off scripts.
Incorrect (page-specific controller that only works in one place):
// app/javascript/controllers/settings_page_toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["settingsPanel"]
toggle() {
this.settingsPanelTarget.classList.toggle("hidden")
}
}<%# Only usable on the settings page %>
<div data-controller="settings-page-toggle">
<button data-action="settings-page-toggle#toggle">Show Settings</button>
<div data-settings-page-toggle-target="settingsPanel" class="hidden">
...
</div>
</div>Correct (generic toggle controller usable anywhere):
// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content"]
static classes = ["toggle"] // configurable via data attribute
static values = {
open: { type: Boolean, default: false }
}
connect() {
this.sync()
}
toggle() {
this.openValue = !this.openValue
}
openValueChanged() {
this.sync()
}
sync() {
const toggleClass = this.hasToggleClass ? this.toggleClass : "hidden"
this.contentTargets.forEach(target => {
target.classList.toggle(toggleClass, !this.openValue)
})
}
}<%# Settings page sidebar %>
<div data-controller="toggle">
<button data-action="toggle#toggle">Toggle Sidebar</button>
<div data-toggle-target="content" class="hidden">...</div>
</div>
<%# FAQ accordion — same controller %>
<div data-controller="toggle">
<button data-action="toggle#toggle">What is your return policy?</button>
<div data-toggle-target="content" class="hidden">...</div>
</div>
<%# Mobile menu — same controller, different toggle class %>
<div data-controller="toggle" data-toggle-toggle-class="translate-x-full">
<button data-action="toggle#toggle">Menu</button>
<nav data-toggle-target="content" class="translate-x-full">...</nav>
</div>
<%# Start open %>
<div data-controller="toggle" data-toggle-open-value="true">
<button data-action="toggle#toggle">Collapse Details</button>
<div data-toggle-target="content">Visible by default</div>
</div>Good general-purpose controller names: toggle, clipboard, dropdown, modal, autosave, countdown, char-count, filter, sortable, tabs.
Bad page-specific controller names: dashboard-sidebar, settings-form, user-profile-tabs, checkout-payment.
Reference: Stimulus Handbook
Components Consume Tokens, Never Define Their Own
Every design value in a component should trace back to a global token. When components define their own hardcoded values (8px, #3b82f6, 0.75rem), the token system fractures. Visual consistency degrades silently because there is no central place to audit or update these values. If a component truly needs a unique value, promote it to the global token set so it becomes visible and governable.
Incorrect (component-scoped hardcoded values):
/* app/assets/stylesheets/components/card.css */
.card {
border-radius: 8px; /* Where did 8px come from? */
padding: 24px; /* Is this consistent with other components? */
background: #ffffff;
border: 1px solid #e5e7eb; /* Hardcoded gray — won't update with theme */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.card-header {
font-size: 18px; /* Doesn't match the typography scale */
font-weight: 600;
margin-bottom: 12px; /* Another unexplained number */
color: #1f2937;
}Correct (components consume global tokens via utilities):
/* app/assets/stylesheets/application.css */
@import "tailwindcss";
@theme {
--color-primary: oklch(0.55 0.2 240);
--color-surface: oklch(0.99 0 0);
--color-border: oklch(0.9 0 0);
--color-text: oklch(0.15 0 0);
--color-text-muted: oklch(0.45 0 0);
--radius-default: 0.5rem;
--radius-lg: 0.75rem;
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.05);
--shadow-md: 0 4px 6px oklch(0 0 0 / 0.07);
}<%# app/views/shared/_card.html.erb %>
<%# No custom CSS needed — tokens consumed via Tailwind utilities %>
<div class="bg-surface border border-border rounded-lg shadow-sm p-6">
<h3 class="text-xl font-semibold text-text mb-3">
<%= title %>
</h3>
<div class="text-base text-text-muted">
<%= yield %>
</div>
</div>When a component needs a unique value, promote it:
/* Don't do this inside a component stylesheet */
.avatar {
width: 40px; /* Component-specific hardcoded value */
height: 40px;
}
/* Instead, add it to the global token set */
@theme {
--spacing-avatar: 2.5rem;
--spacing-avatar-sm: 2rem;
--spacing-avatar-lg: 3rem;
}<%# Then reference the token via width/height utilities %>
<img src="<%= user.avatar_url %>"
class="w-avatar h-avatar rounded-full"
alt="<%= user.name %>">The only exception is truly one-off decorative values (a specific animation curve, a unique illustration position) that have no chance of reuse. Even then, prefer a CSS variable referencing global tokens over a hardcoded value.
Reference: Tailwind CSS v4 Theme Configuration
Related skills
FAQ
What does rails-design-system do?
rails-design-system: A skill for development. This provides functionality for development workflows.
When should I use rails-design-system?
When you need to use rails-design-system for development tasks, or when rails-design-system: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
rails-design-system.