User Experience Design Patterns
This skill provides production-ready UX patterns for Rails applications, covering responsive design, animations, dark mode, loading states, and performance optimization.
When to Use This Skill
Invoke this skill when:
-
Implementing responsive layouts with mobile-first approach
-
Adding animations and transitions for micro-interactions
-
Implementing dark mode support with TailAdmin
-
Creating loading states (skeletons, progress indicators)
-
Designing form UX (validation, multi-step, auto-save)
-
Optimizing performance for Core Web Vitals
-
Building toast notifications and feedback systems
- Mobile-First Responsive Design
1.1 Breakpoint Strategy
Use Tailwind CSS breakpoints consistently:
/* Tailwind Breakpoints (mobile-first) / sm: 640px / Small devices (landscape phones) / md: 768px / Medium devices (tablets) / lg: 1024px / Large devices (laptops) / xl: 1280px / Extra large devices (desktops) / 2xl: 1536px / 2X large devices (large monitors) */
Mobile-First Pattern:
<%# Base styles = mobile, then layer up %> <div class=" p-4 <%# Mobile: 1rem padding %> sm:p-6 <%# Small: 1.5rem padding %> md:p-8 <%# Medium: 2rem padding %> lg:p-10 <%# Large: 2.5rem padding %>
grid grid-cols-1 <%# Mobile: single column %> sm:grid-cols-2 <%# Small: two columns %> lg:grid-cols-3 <%# Large: three columns %> xl:grid-cols-4 <%# Extra large: four columns %>
gap-4 sm:gap-6 lg:gap-8 "> <%= yield %> </div>
1.2 Touch Targets
Minimum touch target sizes for mobile accessibility:
<%# Minimum 44x44px touch targets (WCAG 2.2) %> <button class=" min-h-[44px] min-w-[44px] p-3 touch-manipulation <%# Disable double-tap zoom %> "> <%= content %> </button>
<%# Icon buttons need explicit sizing %> <button class=" h-11 w-11 <%# 44px %> flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation " aria-label="<%= action_label %>"
<%= icon %> </button>
1.3 Responsive Typography
<%# Fluid typography with clamp %> <h1 class=" text-2xl <%# Mobile: 1.5rem %> sm:text-3xl <%# Small: 1.875rem %> md:text-4xl <%# Medium: 2.25rem %> lg:text-5xl <%# Large: 3rem %> font-bold leading-tight tracking-tight "> <%= @page.title %> </h1>
<%# Body text %> <p class=" text-base <%# 1rem %> md:text-lg <%# 1.125rem on larger screens %> leading-relaxed text-gray-700 dark:text-gray-300 "> <%= @page.description %> </p>
1.4 Responsive Navigation Pattern
<%# Mobile: hamburger menu, Desktop: horizontal nav %> <nav class="relative"> <%# Desktop navigation %> <div class="hidden md:flex items-center space-x-6"> <% navigation_items.each do |item| %> <%= link_to item.label, item.path, class: " px-3 py-2 text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors " %> <% end %> </div>
<%# Mobile hamburger %> <button class="md:hidden p-2" data-controller="mobile-nav" data-action="click->mobile-nav#toggle" aria-expanded="false" aria-controls="mobile-menu"
<span class="sr-only">Open menu</span>
<%= render_icon :menu, class: "h-6 w-6" %>
</button>
<%# Mobile menu (hidden by default) %> <div id="mobile-menu" class=" md:hidden absolute top-full left-0 right-0 bg-white dark:bg-gray-800 shadow-lg hidden " data-mobile-nav-target="menu"
<% navigation_items.each do |item| %>
<%= link_to item.label, item.path, class: "
block px-4 py-3
border-b border-gray-100 dark:border-gray-700
hover:bg-gray-50 dark:hover:bg-gray-700
" %>
<% end %>
</div> </nav>
1.5 Responsive Tables
<%# Card layout on mobile, table on desktop %> <div class="overflow-x-auto"> <%# Desktop table (hidden on mobile) %> <table class="hidden md:table w-full"> <thead class="bg-gray-50 dark:bg-gray-700"> <tr> <th class="px-4 py-3 text-left text-sm font-semibold">Name</th> <th class="px-4 py-3 text-left text-sm font-semibold">Email</th> <th class="px-4 py-3 text-left text-sm font-semibold">Status</th> <th class="px-4 py-3 text-right text-sm font-semibold">Actions</th> </tr> </thead> <tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <% @users.each do |user| %> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50"> <td class="px-4 py-3"><%= user.name %></td> <td class="px-4 py-3"><%= user.email %></td> <td class="px-4 py-3"><%= render_status_badge(user.status) %></td> <td class="px-4 py-3 text-right"><%= render_actions(user) %></td> </tr> <% end %> </tbody> </table>
<%# Mobile card layout %> <div class="md:hidden space-y-4"> <% @users.each do |user| %> <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4"> <div class="flex items-center justify-between mb-2"> <h3 class="font-semibold"><%= user.name %></h3> <%= render_status_badge(user.status) %> </div> <p class="text-sm text-gray-600 dark:text-gray-400 mb-3"> <%= user.email %> </p> <div class="flex justify-end space-x-2"> <%= render_actions(user) %> </div> </div> <% end %> </div> </div>
- Animation & Transition Patterns
2.1 Transition Timing Functions
/* Recommended easing functions / .ease-smooth { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); / Default Tailwind */ }
.ease-bounce { transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55); }
.ease-elastic { transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275); }
2.2 Micro-Interactions
<%# Button hover/active states %> <button class=" px-4 py-2 bg-primary-600 text-white rounded-lg
<%# Smooth transitions %> transition-all duration-200 ease-out
<%# Hover: slight lift %> hover:bg-primary-700 hover:shadow-md hover:-translate-y-0.5
<%# Active: press down %> active:translate-y-0 active:shadow-sm
<%# Focus: ring %> focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 "> <%= content %> </button>
<%# Card hover effect %> <div class=" bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6
transition-all duration-300 ease-out
hover:shadow-lg hover:-translate-y-1
cursor-pointer "> <%= yield %> </div>
2.3 Page Transitions with Turbo
// app/javascript/controllers/page_transition_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["content"]
connect() { document.addEventListener("turbo:before-visit", this.fadeOut.bind(this)) document.addEventListener("turbo:load", this.fadeIn.bind(this)) }
fadeOut() { this.contentTarget.classList.add("opacity-0", "translate-y-2") }
fadeIn() { requestAnimationFrame(() => { this.contentTarget.classList.remove("opacity-0", "translate-y-2") }) } }
<%# In layout %> <main class="transition-all duration-300 ease-out" data-controller="page-transition" data-page-transition-target="content"
<%= yield %> </main>
2.4 Modal/Drawer Animations
// app/javascript/controllers/modal_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["backdrop", "panel"] static values = { open: Boolean }
connect() { if (this.openValue) this.open() }
open() { // Prevent body scroll document.body.classList.add("overflow-hidden")
// Show modal
this.element.classList.remove("hidden")
// Animate in (next frame for transition)
requestAnimationFrame(() => {
this.backdropTarget.classList.remove("opacity-0")
this.panelTarget.classList.remove("opacity-0", "scale-95", "translate-y-4")
})
}
close() { // Animate out this.backdropTarget.classList.add("opacity-0") this.panelTarget.classList.add("opacity-0", "scale-95", "translate-y-4")
// Hide after animation
setTimeout(() => {
this.element.classList.add("hidden")
document.body.classList.remove("overflow-hidden")
}, 300)
}
backdropClick(event) { if (event.target === this.backdropTarget) { this.close() } } }
<div class="fixed inset-0 z-50 hidden" data-controller="modal" data-modal-open-value="false" data-action="keydown.esc->modal#close"
<%# Backdrop %> <div class=" fixed inset-0 bg-black/50 transition-opacity duration-300 opacity-0 " data-modal-target="backdrop" data-action="click->modal#backdropClick"
</div>
<%# Panel %> <div class="fixed inset-0 flex items-center justify-center p-4"> <div class=" bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto
transition-all duration-300 ease-out
opacity-0 scale-95 translate-y-4
"
data-modal-target="panel"
role="dialog"
aria-modal="true"
>
<%= yield %>
</div>
</div> </div>
2.5 Respecting Reduced Motion
/* Always include reduced motion support */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } }
<%# Tailwind classes with motion-safe %> <div class=" motion-safe:transition-all motion-safe:duration-300 motion-safe:hover:-translate-y-1 motion-reduce:transition-none "> <%= content %> </div>
// Check in JavaScript const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches
if (!prefersReducedMotion) { // Apply animations }
2.6 Loading Animations
<%# Spinner %> <div class=" h-8 w-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin " role="status"> <span class="sr-only">Loading...</span> </div>
<%# Pulse dots %> <div class="flex space-x-1" role="status"> <span class="sr-only">Loading...</span> <div class="h-2 w-2 bg-primary-600 rounded-full animate-bounce [animation-delay:-0.3s]"></div> <div class="h-2 w-2 bg-primary-600 rounded-full animate-bounce [animation-delay:-0.15s]"></div> <div class="h-2 w-2 bg-primary-600 rounded-full animate-bounce"></div> </div>
<%# Progress bar %> <div class="h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"> <div class="h-full bg-primary-600 transition-all duration-500 ease-out" style="width: <%= @progress %>%" role="progressbar" aria-valuenow="<%= @progress %>" aria-valuemin="0" aria-valuemax="100"
</div> </div>
- Dark Mode Implementation
3.1 TailAdmin Dark Mode System
TailAdmin uses class-based dark mode. Toggle the dark class on <html> :
// app/javascript/controllers/dark_mode_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["toggle"] static values = { mode: String }
connect() { this.modeValue = this.loadPreference() this.apply() }
toggle() { this.modeValue = this.modeValue === "dark" ? "light" : "dark" this.apply() this.savePreference() }
apply() { if (this.modeValue === "dark") { document.documentElement.classList.add("dark") } else { document.documentElement.classList.remove("dark") } }
loadPreference() { const stored = localStorage.getItem("theme") if (stored) return stored
// Fall back to system preference
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark"
}
return "light"
}
savePreference() { localStorage.setItem("theme", this.modeValue) } }
3.2 Dark Mode Color Patterns
<%# Always pair light and dark classes %> <div class=" bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border-gray-200 dark:border-gray-700 "> <h2 class=" text-gray-900 dark:text-white font-semibold "> <%= @title %> </h2>
<p class=" text-gray-600 dark:text-gray-400 "> <%= @description %> </p>
<%# Muted/secondary text %> <span class=" text-gray-500 dark:text-gray-500 "> <%= @metadata %> </span> </div>
3.3 Dark Mode Toggle Component
<%# Dark mode toggle button %> <button type="button" class=" relative p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 " data-controller="dark-mode" data-action="click->dark-mode#toggle" aria-label="Toggle dark mode"
<%# Sun icon (shown in dark mode) %> <svg class="h-5 w-5 hidden dark:block" fill="currentColor" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" /> </svg>
<%# Moon icon (shown in light mode) %> <svg class="h-5 w-5 block dark:hidden" fill="currentColor" viewBox="0 0 20 20"> <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" /> </svg> </button>
3.4 Prevent Flash on Load
<%# In <head> before any CSS loads %> <script> // Immediately apply theme to prevent flash (function() { const theme = localStorage.getItem('theme'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && prefersDark)) {
document.documentElement.classList.add('dark');
}
})(); </script>
- Loading States & Feedback
4.1 Skeleton Loaders
<%# Card skeleton %> <div class="animate-pulse"> <div class="bg-gray-200 dark:bg-gray-700 rounded-lg h-48 mb-4"></div> <div class="space-y-3"> <div class="bg-gray-200 dark:bg-gray-700 rounded h-4 w-3/4"></div> <div class="bg-gray-200 dark:bg-gray-700 rounded h-4 w-1/2"></div> </div> </div>
<%# Table row skeleton %> <tr class="animate-pulse"> <td class="px-4 py-3"> <div class="bg-gray-200 dark:bg-gray-700 rounded h-4 w-32"></div> </td> <td class="px-4 py-3"> <div class="bg-gray-200 dark:bg-gray-700 rounded h-4 w-48"></div> </td> <td class="px-4 py-3"> <div class="bg-gray-200 dark:bg-gray-700 rounded-full h-6 w-16"></div> </td> </tr>
<%# Avatar skeleton %> <div class="flex items-center space-x-3 animate-pulse"> <div class="bg-gray-200 dark:bg-gray-700 rounded-full h-10 w-10"></div> <div class="space-y-2"> <div class="bg-gray-200 dark:bg-gray-700 rounded h-4 w-24"></div> <div class="bg-gray-200 dark:bg-gray-700 rounded h-3 w-32"></div> </div> </div>
4.2 Skeleton ViewComponent
app/components/skeleton_component.rb
class SkeletonComponent < ViewComponent::Base VARIANTS = { text: "h-4 rounded", title: "h-6 rounded", avatar: "h-10 w-10 rounded-full", button: "h-10 w-24 rounded-lg", card: "h-48 rounded-lg", image: "aspect-video rounded-lg" }.freeze
def initialize(variant: :text, width: nil, count: 1) @variant = variant @width = width @count = count end
def call content_tag :div, class: "animate-pulse #{'space-y-3' if @count > 1}" do safe_join( @count.times.map { skeleton_element }, "\n" ) end end
private
def skeleton_element content_tag :div, nil, class: [ "bg-gray-200 dark:bg-gray-700", VARIANTS[@variant], width_class ].compact.join(" ") end
def width_class return @width if @width.is_a?(String)
case @width
when :full then "w-full"
when :half then "w-1/2"
when :third then "w-1/3"
when :quarter then "w-1/4"
when :three_quarter then "w-3/4"
end
end end
4.3 Turbo Frame Loading States
<%# Frame with loading indicator %> <turbo-frame id="users-list" src="<%= users_path %>" loading="lazy" data-controller="loading-frame"
<%# Loading state (shown while loading) %> <div data-loading-frame-target="loading" class="py-8 text-center"> <div class="inline-flex items-center space-x-2 text-gray-500"> <%= render SkeletonComponent.new(variant: :avatar) %> <span>Loading users...</span> </div> </div> </turbo-frame>
4.4 Button Loading States
<%# Submit button with loading state %> <button type="submit" class=" relative px-4 py-2 bg-primary-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed " data-controller="submit-button" data-action="click->submit-button#loading"
<%# Normal state %> <span data-submit-button-target="label"> Save Changes </span>
<%# Loading state (hidden by default) %> <span data-submit-button-target="loading" class="absolute inset-0 flex items-center justify-center hidden"
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</span> </button>
// app/javascript/controllers/submit_button_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["label", "loading"]
loading() { this.element.disabled = true this.labelTarget.classList.add("invisible") this.loadingTarget.classList.remove("hidden") }
reset() { this.element.disabled = false this.labelTarget.classList.remove("invisible") this.loadingTarget.classList.add("hidden") } }
4.5 Optimistic UI Updates
// app/javascript/controllers/optimistic_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["item"] static values = { url: String }
async toggle(event) { const checkbox = event.currentTarget const originalState = !checkbox.checked
// Optimistic update - UI changes immediately
this.updateUI(checkbox.checked)
try {
const response = await fetch(this.urlValue, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector("[name='csrf-token']").content
},
body: JSON.stringify({ completed: checkbox.checked })
})
if (!response.ok) throw new Error("Failed to update")
} catch (error) {
// Rollback on failure
checkbox.checked = originalState
this.updateUI(originalState)
this.showError("Failed to update. Please try again.")
}
}
updateUI(completed) { if (completed) { this.itemTarget.classList.add("opacity-50", "line-through") } else { this.itemTarget.classList.remove("opacity-50", "line-through") } } }
- Error & Success Messaging
5.1 Toast Notifications
app/components/toast_component.rb
class ToastComponent < ViewComponent::Base VARIANTS = { success: { bg: "bg-green-50 dark:bg-green-900/50", border: "border-green-500", text: "text-green-800 dark:text-green-200", icon: "check-circle" }, error: { bg: "bg-red-50 dark:bg-red-900/50", border: "border-red-500", text: "text-red-800 dark:text-red-200", icon: "x-circle" }, warning: { bg: "bg-yellow-50 dark:bg-yellow-900/50", border: "border-yellow-500", text: "text-yellow-800 dark:text-yellow-200", icon: "exclamation-triangle" }, info: { bg: "bg-blue-50 dark:bg-blue-900/50", border: "border-blue-500", text: "text-blue-800 dark:text-blue-200", icon: "information-circle" } }.freeze
def initialize(variant: :info, message:, dismissable: true, auto_dismiss: 5000) @variant = variant @message = message @dismissable = dismissable @auto_dismiss = auto_dismiss end
def styles VARIANTS[@variant] end end
<%# app/components/toast_component.html.erb %> <div class=" flex items-start gap-3 p-4 rounded-lg border-l-4 <%= styles[:bg] %> <%= styles[:border] %> <%= styles[:text] %> shadow-lg " data-controller="toast" data-toast-auto-dismiss-value="<%= @auto_dismiss %>" role="alert"
<%= render_icon styles[:icon], class: "h-5 w-5 flex-shrink-0 mt-0.5" %>
<p class="flex-1 text-sm font-medium"> <%= @message %> </p>
<% if @dismissable %> <button type="button" class="flex-shrink-0 p-1 rounded hover:bg-black/10" data-action="click->toast#dismiss" aria-label="Dismiss" > <%= render_icon :x, class: "h-4 w-4" %> </button> <% end %> </div>
5.2 Toast Container with Turbo Streams
<%# app/views/layouts/_toast_container.html.erb %> <div id="toast-container" class=" fixed bottom-4 right-4 z-50 flex flex-col gap-3 max-w-sm w-full pointer-events-none " aria-live="polite" aria-atomic="true"
<%# Toasts will be inserted here via Turbo Streams %> </div>
app/controllers/concerns/toastable.rb
module Toastable extend ActiveSupport::Concern
def toast(message, variant: :info) respond_to do |format| format.turbo_stream do render turbo_stream: turbo_stream.append( "toast-container", ToastComponent.new(variant: variant, message: message) ) end end end end
5.3 Inline Form Validation
<%# Form field with inline error %> <div data-controller="form-field"> <label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
Email Address
</label>
<input type="email" id="email" name="user[email]" value="<%= @user.email %>" class=" w-full px-3 py-2 border rounded-lg transition-colors
<%= if @user.errors[:email].any? %>
border-red-500
focus:border-red-500
focus:ring-red-500
<% else %>
border-gray-300 dark:border-gray-600
focus:border-primary-500
focus:ring-primary-500
<% end %>
"
aria-invalid="<%= @user.errors[:email].any? %>"
aria-describedby="<%= 'email-error' if @user.errors[:email].any? %>"
data-action="blur->form-field#validate"
/>
<% if @user.errors[:email].any? %> <p id="email-error" class="mt-1 text-sm text-red-600 dark:text-red-400"> <%= @user.errors[:email].first %> </p> <% end %> </div>
5.4 Success States
<%# Success checkmark animation %> <div class="flex flex-col items-center py-8"> <div class=" h-16 w-16 rounded-full bg-green-100 dark:bg-green-900/50 flex items-center justify-center mb-4 "> <svg class="h-8 w-8 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" class="animate-[draw_0.5s_ease-out_forwards]" style="stroke-dasharray: 20; stroke-dashoffset: 20;" /> </svg> </div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white"> Successfully Saved! </h3> <p class="text-gray-600 dark:text-gray-400"> Your changes have been saved. </p> </div>
<style> @keyframes draw { to { stroke-dashoffset: 0; } } </style>
- Form UX Patterns
6.1 Multi-Step Form Wizard
<%# Step indicator %> <nav aria-label="Progress" class="mb-8"> <ol class="flex items-center justify-center space-x-4"> <% steps.each_with_index do |step, index| %> <li class="flex items-center"> <% if index < current_step %> <%# Completed step %> <span class=" flex items-center justify-center h-10 w-10 rounded-full bg-primary-600 text-white "> <%= render_icon :check, class: "h-5 w-5" %> </span> <% elsif index == current_step %> <%# Current step %> <span class=" flex items-center justify-center h-10 w-10 rounded-full border-2 border-primary-600 bg-white dark:bg-gray-800 text-primary-600 font-semibold "> <%= index + 1 %> </span> <% else %> <%# Future step %> <span class=" flex items-center justify-center h-10 w-10 rounded-full border-2 border-gray-300 dark:border-gray-600 text-gray-500 "> <%= index + 1 %> </span> <% end %>
<% unless index == steps.length - 1 %>
<div class="
ml-4 h-0.5 w-16
<%= index < current_step ? 'bg-primary-600' : 'bg-gray-300 dark:bg-gray-600' %>
"></div>
<% end %>
</li>
<% end %>
</ol> </nav>
6.2 Auto-Save Draft
// app/javascript/controllers/autosave_controller.js import { Controller } from "@hotwired/stimulus" import { debounce } from "lodash-es"
export default class extends Controller { static targets = ["form", "status"] static values = { url: String, delay: { type: Number, default: 2000 } }
connect() { this.save = debounce(this.save.bind(this), this.delayValue) }
changed() { this.statusTarget.textContent = "Unsaved changes..." this.statusTarget.classList.remove("text-green-600") this.statusTarget.classList.add("text-yellow-600") this.save() }
async save() { const formData = new FormData(this.formTarget)
try {
this.statusTarget.textContent = "Saving..."
const response = await fetch(this.urlValue, {
method: "PATCH",
body: formData,
headers: {
"X-CSRF-Token": document.querySelector("[name='csrf-token']").content,
"Accept": "application/json"
}
})
if (response.ok) {
this.statusTarget.textContent = "Saved"
this.statusTarget.classList.remove("text-yellow-600")
this.statusTarget.classList.add("text-green-600")
}
} catch (error) {
this.statusTarget.textContent = "Failed to save"
this.statusTarget.classList.add("text-red-600")
}
} }
<div data-controller="autosave" data-autosave-url-value="<%= draft_path(@draft) %>"> <div class="flex items-center justify-between mb-4"> <h2>Edit Draft</h2> <span data-autosave-target="status" class="text-sm text-gray-500" > All changes saved </span> </div>
<%= form_with model: @draft, data: { autosave_target: "form" } do |f| %> <%= f.text_field :title, data: { action: "input->autosave#changed" }, class: "..." %>
<%= f.text_area :content,
data: { action: "input->autosave#changed" },
class: "..."
%>
<% end %> </div>
6.3 Character Counter
<div data-controller="character-counter" data-character-counter-max-value="280"> <label for="bio" class="block text-sm font-medium mb-1"> Bio </label>
<textarea id="bio" name="user[bio]" rows="4" class="w-full px-3 py-2 border rounded-lg" data-character-counter-target="input" data-action="input->character-counter#count" maxlength="280"
<%= @user.bio %></textarea>
<div class="flex justify-end mt-1"> <span data-character-counter-target="count" class="text-sm text-gray-500" > <%= @user.bio&.length || 0 %>/280 </span> </div> </div>
- Performance Optimization
7.1 Lazy Loading Images
<%# Native lazy loading %> <%= image_tag @product.image, loading: "lazy", decoding: "async", class: "w-full h-auto rounded-lg", alt: @product.name %>
<%# With blur-up placeholder %> <div class="relative overflow-hidden rounded-lg bg-gray-200"> <%# Tiny placeholder (inline base64) %> <img src="<%= @product.placeholder_url %>" class="absolute inset-0 w-full h-full object-cover blur-lg scale-110" aria-hidden="true" />
<%# Full image %> <img src="<%= @product.image_url %>" loading="lazy" class="relative w-full h-auto" alt="<%= @product.name %>" onload="this.previousElementSibling.remove()" /> </div>
7.2 Intersection Observer for Lazy Loading
// app/javascript/controllers/lazy_load_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["content"] static values = { url: String }
connect() { this.observer = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { this.load() this.observer.disconnect() } }) }, { rootMargin: "100px" } )
this.observer.observe(this.element)
}
async load() { const response = await fetch(this.urlValue, { headers: { "Accept": "text/html" } })
if (response.ok) {
const html = await response.text()
this.contentTarget.innerHTML = html
}
}
disconnect() { this.observer?.disconnect() } }
7.3 Core Web Vitals Optimization
<%# Preload critical resources %> <link rel="preload" href="<%= asset_path('fonts/inter.woff2') %>" as="font" type="font/woff2" crossorigin> <link rel="preload" href="<%= image_path('hero.webp') %>" as="image">
<%# Preconnect to external resources %> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="dns-prefetch" href="https://analytics.example.com">
<%# Above-the-fold critical CSS (inline) %> <style> /* Critical CSS for LCP element / .hero { / ... */ } </style>
<%# Defer non-critical CSS %> <link rel="stylesheet" href="<%= stylesheet_path('application') %>" media="print" onload="this.media='all'">
7.4 Prevent Layout Shift (CLS)
<%# Always set dimensions on images %> <img src="<%= @image.url %>" width="800" height="600" class="w-full h-auto" alt="<%= @image.alt %>" />
<%# Reserve space for dynamic content %> <div style="min-height: 400px;" data-controller="lazy-load" data-lazy-load-url-value="<%= comments_path %>"
<%# Skeleton placeholder %> <%= render SkeletonComponent.new(variant: :card, count: 3) %> </div>
<%# Aspect ratio containers %> <div class="aspect-video bg-gray-200 rounded-lg overflow-hidden"> <iframe src="<%= @video.embed_url %>" loading="lazy" class="w-full h-full" allowfullscreen
</iframe> </div>
Quick Reference Checklist
Responsive Design
-
Mobile-first approach (base styles = mobile)
-
Touch targets minimum 44x44px
-
Tables convert to cards on mobile
-
Navigation collapses to hamburger
-
Typography scales appropriately
Animations
-
Transitions use easing (not linear)
-
Duration 200-300ms for micro-interactions
-
prefers-reduced-motion respected
-
No animations on initial load
Dark Mode
-
All colors have dark: variants
-
Toggle saved to localStorage
-
System preference detected
-
No flash on page load
Loading States
-
Skeleton loaders match content
-
Buttons show loading spinner
-
Forms disable during submit
-
Optimistic UI where appropriate
Performance
-
Images lazy loaded
-
Critical CSS inlined
-
Fonts preloaded
-
Layout shift prevented
Related Skills
-
accessibility-patterns - WCAG 2.2 compliance
-
hotwire-patterns - Turbo and Stimulus integration
-
viewcomponents-specialist - Component architecture
-
tailadmin-patterns - TailAdmin-specific patterns