user experience design

User Experience Design Patterns

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "user experience design" with this command: npx skills add kaakati/rails-enterprise-dev/kaakati-rails-enterprise-dev-user-experience-design

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

  1. 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"

&#x3C;span class="sr-only">Open menu&#x3C;/span>
&#x3C;%= 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"

&#x3C;% navigation_items.each do |item| %>
  &#x3C;%= 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
  " %>
&#x3C;% 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>

  1. 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"
>
  &#x3C;%= yield %>
&#x3C;/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>

  1. 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 &#x26;&#x26; prefersDark)) {
  document.documentElement.classList.add('dark');
}

})(); </script>

  1. 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"

&#x3C;svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
  &#x3C;circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
  &#x3C;path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
&#x3C;/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") } } }

  1. 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

  &#x3C;%= if @user.errors[:email].any? %>
    border-red-500
    focus:border-red-500
    focus:ring-red-500
  &#x3C;% else %>
    border-gray-300 dark:border-gray-600
    focus:border-primary-500
    focus:ring-primary-500
  &#x3C;% end %>
"
aria-invalid="&#x3C;%= @user.errors[:email].any? %>"
aria-describedby="&#x3C;%= '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>

  1. 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 %>

    &#x3C;% unless index == steps.length - 1 %>
      &#x3C;div class="
        ml-4 h-0.5 w-16
        &#x3C;%= index &#x3C; current_step ? 'bg-primary-600' : 'bg-gray-300 dark:bg-gray-600' %>
      ">&#x3C;/div>
    &#x3C;% end %>
  &#x3C;/li>
&#x3C;% 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: "..." %>

&#x3C;%= 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>

  1. 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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

flutter conventions & best practices

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

getx state management patterns

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

ruby oop patterns

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

rails localization (i18n) - english & arabic

No summary provided by upstream source.

Repository SourceNeeds Review