Frontend patterns for Rails applications using Slim templates, Stimulus JavaScript framework, CSS with Optics utilities. Use when building views, adding interactivity, styling components, or when the user mentions Slim, Stimulus, JavaScript, CSS, or frontend development.
Install
npx skillscat add rolemodel/rolemodel-skills/frontend-patterns Install via the SkillsCat registry.
Frontend Patterns
Tech Stack
- Slim - HTML templating
- Stimulus - JavaScript interactions
- CSS - Styling
- Optics - CSS styling framework
- Simple Form - Form builder
Slim Templates
Conventions
- Use Ruby 3+ syntax (e.g., keyword arguments with
:) - Keep logic minimal in views
- Extract complex rendering to helpers or partials
- Use locals for partial data passing
- Always prioritize DRY principles - extract repeated markup into partials
- Extract partials when logic or markup is repeated more than once
- Never use inline styles
When to Use Helpers vs Partials
Use Helper Methods when:
- Simple conditional logic that returns HTML with different text/classes
- Formatting data (dates, currency, durations)
- Generating single HTML elements with varying attributes
- Logic is stateless and doesn't need multiple elements
- Example:
status_badge(status),format_duration(seconds)
Use Partials when:
- Complex markup structure (multiple nested elements)
- Reusable UI components with layout
- Need to render collections
- Significant HTML that would clutter a helper
- Example:
_time_entry_row.html.slim,_timer_form.html.slim
Rule of thumb: If it's primarily conditional text/classes in a single element, use a helper. If it's a structure/layout, use a partial.
Partial Extraction Guidelines
- Extract forms on the
newandeditpages into_formpartials - Extract repeated structures into component partials
- Use descriptive partial names:
_time_entry_row,_project_selector,_status_badge - Place partials in same directory as parent view or in
shared/for cross-feature use - Always use keyword arguments for partial locals:
render 'row', time_entry:, show_actions: true
Partial Organization
app/views/
time_entries/
edit.html.slim # Edit view
index.html.slim # Main view
new.html.slim # New view
show.html.slim # Show view
_time_entries.html.slim # Table collection of rows
_time_entry.html.slim # Individual row
_form.html.slim # Time Entries form
shared/
_status_badge.html.slim # Reusable badge
_empty_state.html.slim # Empty state patternConditional class names
Use the rails class_names helper to manage conditional class names in Slim templates.
button.btn class=class_names('btn--active': active) Click MeExample
-# locals: (user:, active: false)
.user-card class=('active' if active)
h3 = user.name
p = user.emailSimple Form
Overview
Always use Simple Form for forms. Never use form_with, form_for, or Rails form helpers directly.
Basic Model Form
= simple_form_for @user do |f|
= f.input :first_name
= f.input :last_name
= f.input :email, required: true
.form__actions
= link_to 'Cancel', :back, class: 'btn btn--outline'
= f.submit 'Save', class: 'btn btn--primary'Non-Model Form (with URL)
For forms without a model (like bulk actions or search forms):
= simple_form_for :search, url: search_path, method: :get do |f|
= f.input :query, label: 'Search'
= f.submit 'Search', class: 'btn btn--primary'Important: When using simple_form_for :symbol, params are nested under the symbol:
# View: simple_form_for :time_entry
# Params received: { time_entry: { task_id: 1, description: "text" } }
# Access with: params.dig(:time_entry, :task_id)Form with HTML Options
= simple_form_for @record, html: { id: 'custom-form', class: 'special-form' } do |f|
= f.input :nameForm with Data Attributes (Turbo)
= simple_form_for @record, data: { turbo_frame: '_top' } do |f|
= f.input :nameCommon Input Types
/ Text input
= f.input :name
/ Text area
= f.input :description, as: :text, input_html: { rows: 4 }
/ Select dropdown
= f.input :project_id, collection: @projects, prompt: 'Select a project...'
/ Boolean checkbox
= f.input :active, as: :boolean
/ Date picker
= f.input :start_date, as: :date
/ With placeholder
= f.input :email, placeholder: 'user@example.com'
/ With custom input attributes
= f.input :description, input_html: { rows: 3, required: true, data: { controller: 'auto-save' } }Collections and Associations
/ Simple collection
= f.input :category_id, collection: @categories
/ With custom text/value methods
= f.input :project_id, collection: @projects, label_method: :name, value_method: :id
/ Grouped collection
= f.input :task_id, as: :grouped_select, collection: @projects, group_method: :tasks
/ With prompt
= f.input :status, collection: ['pending', 'approved', 'rejected'], prompt: 'Select status...'Custom Labels and Hints
= f.input :email, label: 'Email Address', hint: 'We will never share your email'
= f.input :password, label: 'Password', placeholder: 'At least 8 characters'Disabled Inputs
= f.input :task_id, disabled: true, input_html: { data: { target: 'form.taskSelect' } }Hidden Fields
= f.hidden_field :organization_id, value: current_user.organization_idSubmit Buttons
/ Standard submit
= f.submit 'Save', class: 'btn btn--primary'
/ With data attributes
= f.submit 'Save', class: 'btn btn--primary', data: { disable_with: 'Saving...' }
/ Associated with external form (for modal footers)
= f.submit 'Save', form: 'my-form-id', class: 'btn btn--primary'Form Actions Pattern
Standard pattern for form button groups:
.form__actions
= link_to 'Cancel', :back, class: 'btn btn--outline'
= f.submit 'Save', class: 'btn btn--primary'Bulk Action Forms
For forms that collect checkboxes without inputs:
= simple_form_for :bulk_action, url: bulk_approve_path, method: :post, html: { id: 'bulk-form' } do |f|
/ Form will collect checked checkboxes via form attribute
/ Checkboxes reference the form
= check_box_tag 'entry_ids[]', entry.id, false, form: 'bulk-form'Modal Forms
Forms that submit within modals and redirect to parent page:
= simple_form_for @record, html: { id: 'modal-form' }, data: { turbo_frame: '_top' } do |f|
= f.input :reason, as: :text, input_html: { rows: 3, required: true }
-# In modal footer (outside form)
- content_for :modal_actions do
= button_tag 'Submit', type: 'submit', form: 'modal-form', class: 'btn btn--primary'Best Practices
- Always use
simple_form_for, neverform_withorform_for - Use
:symbolfor non-model forms with url parameter - Use
@modelfor model-based forms - Leverage Simple Form's automatic label generation
- Use
input_htmlfor custom HTML attributes on the input element - Use
htmloption for attributes on the form element itself - Keep forms accessible with proper labels
- Use
.form__actionsfor button groups
Stimulus Controllers
Structure
- One controller per behavior
- Use data attributes for configuration
- Keep controllers focused and composable
- Follow naming conventions (kebab-case in HTML)
Example
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["output"]
static values = { url: String }
connect() {
// Initialization
}
perform() {
// Action logic
}
}CSS & Optics
Guidelines
- Use Optics utility classes where applicable
- Keep custom CSS minimal and scoped
- Follow BEM or similar naming for custom components
- Avoid inline styles
Future Topics
- Turbo Frames and Streams patterns
- Form styling conventions
- Icon helper usage
- Responsive design patterns
- Animation and transition guidelines