Agent skill
Accessibility Patterns
WCAG 2.2 Level AA compliance patterns for Rails applications including ARIA implementation, keyboard navigation, focus management, screen reader support, and color contrast requirements. Use this skill when implementing any user-facing UI to ensure accessibility compliance. Trigger keywords: accessibility, a11y, WCAG, ARIA, screen reader, keyboard navigation, focus, color contrast, disability, assistive technology
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/documents/accessibility-patterns-kaakati-rails-enterprise-dev
SKILL.md
Accessibility Patterns
Comprehensive WCAG 2.2 Level AA compliance patterns for Rails applications with ViewComponents, Hotwire, and TailAdmin.
When to Use This Skill
Use this skill when:
- Implementing any user-facing UI component
- Building interactive widgets (modals, dropdowns, tabs)
- Creating forms with validation feedback
- Adding dynamic content updates (Turbo Streams)
- Reviewing existing components for accessibility compliance
- Responding to accessibility audit findings
1. WCAG 2.2 Level AA Quick Reference
1.1 Perceivable
| Criterion | Requirement | Implementation |
|---|---|---|
| 1.1.1 Non-text Content | Alt text for images | alt="descriptive text" or alt="" for decorative |
| 1.3.1 Info and Relationships | Semantic HTML | Use proper heading hierarchy, lists, tables |
| 1.4.3 Contrast (Minimum) | 4.5:1 for text, 3:1 for large text | Verify TailAdmin colors meet ratios |
| 1.4.4 Resize Text | 200% zoom support | Use relative units (rem, em) |
| 1.4.11 Non-text Contrast | 3:1 for UI components | Focus rings, borders, icons |
1.2 Operable
| Criterion | Requirement | Implementation |
|---|---|---|
| 2.1.1 Keyboard | All functionality via keyboard | Tab, Enter, Space, Arrow keys |
| 2.1.2 No Keyboard Trap | Users can navigate away | Proper focus management |
| 2.4.3 Focus Order | Logical tab sequence | DOM order matches visual order |
| 2.4.7 Focus Visible | Clear focus indicator | Tailwind focus rings |
| 2.5.8 Target Size | Minimum 24x24px | Touch targets 44x44px recommended |
1.3 Understandable
| Criterion | Requirement | Implementation |
|---|---|---|
| 3.1.1 Language of Page | Declare page language | <html lang="en"> |
| 3.2.1 On Focus | No unexpected changes | Avoid auto-submit on focus |
| 3.3.1 Error Identification | Identify errors clearly | aria-invalid, role="alert" |
| 3.3.2 Labels or Instructions | Label all inputs | <label> or aria-label |
1.4 Robust
| Criterion | Requirement | Implementation |
|---|---|---|
| 4.1.2 Name, Role, Value | Expose to assistive tech | ARIA roles, states, properties |
| 4.1.3 Status Messages | Announce without focus | aria-live regions |
2. ARIA Roles, States, and Properties
2.1 Landmark Roles
Use semantic HTML with implicit roles:
<%# Prefer semantic HTML over ARIA roles %>
<header> <%# role="banner" implicit %>
<nav> <%# role="navigation" implicit %>
<main> <%# role="main" implicit %>
<aside> <%# role="complementary" implicit %>
<footer> <%# role="contentinfo" implicit %>
<%# Only use explicit roles when semantic HTML not possible %>
<div role="search">
<%= form_with url: search_path, method: :get do |f| %>
<%= f.search_field :q, "aria-label": "Search site" %>
<% end %>
</div>
2.2 Widget Roles
Common interactive widget patterns:
<%# Button %>
<button type="button" aria-pressed="false">Toggle Feature</button>
<%# Link styled as button %>
<a href="#" role="button">Perform Action</a>
<%# Tab Panel %>
<div role="tablist" aria-label="Account Settings">
<button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">
Profile
</button>
<button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2">
Security
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
Profile content...
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
Security content...
</div>
<%# Expandable section %>
<button aria-expanded="false" aria-controls="details-section">
Show Details
</button>
<div id="details-section" hidden>
Expanded content...
</div>
<%# Menu %>
<button aria-haspopup="menu" aria-expanded="false">
Options
</button>
<ul role="menu" hidden>
<li role="menuitem"><a href="#">Edit</a></li>
<li role="menuitem"><a href="#">Delete</a></li>
</ul>
<%# Alert Dialog %>
<div role="alertdialog" aria-modal="true" aria-labelledby="dialog-title" aria-describedby="dialog-desc">
<h2 id="dialog-title">Confirm Delete</h2>
<p id="dialog-desc">Are you sure you want to delete this item?</p>
<button>Cancel</button>
<button>Delete</button>
</div>
2.3 Live Regions
Announce dynamic content changes:
<%# Polite announcement (after current speech) %>
<div aria-live="polite" aria-atomic="true" class="sr-only">
<%= flash[:notice] %>
</div>
<%# Assertive announcement (interrupt) %>
<div role="alert" aria-live="assertive">
<%= flash[:alert] %>
</div>
<%# Status updates %>
<div role="status" aria-live="polite">
Showing <%= @items.count %> of <%= @total %> items
</div>
<%# Progress %>
<div role="progressbar"
aria-valuenow="<%= @progress %>"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Upload progress">
<%= @progress %>% complete
</div>
2.4 Relationship Properties
Connect related elements:
<%# Labeling %>
<input type="text" id="email" aria-labelledby="email-label email-hint">
<label id="email-label" for="email">Email</label>
<span id="email-hint">We'll never share your email</span>
<%# Describing %>
<input type="password" aria-describedby="password-requirements">
<div id="password-requirements">
Password must be at least 8 characters
</div>
<%# Error messages %>
<input type="email"
aria-invalid="true"
aria-describedby="email-error"
aria-errormessage="email-error">
<div id="email-error" role="alert">
Please enter a valid email address
</div>
<%# Controlling %>
<button aria-controls="dropdown-menu" aria-expanded="false">
Menu
</button>
<ul id="dropdown-menu" hidden>...</ul>
<%# Owns (for non-DOM parent-child) %>
<div role="listbox" aria-owns="option-1 option-2 option-3">
<%# Options may be rendered elsewhere in DOM %>
</div>
3. Keyboard Navigation Patterns
3.1 Focus Order and Tab Sequence
<%# Natural tab order follows DOM order %>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
<%# Remove from tab order (but keep accessible) %>
<button tabindex="-1">Skip This</button>
<%# Add to tab order (non-focusable elements) %>
<div tabindex="0" role="button">Custom Button</div>
<%# NEVER use positive tabindex %>
<%# BAD: <input tabindex="1"> %>
3.2 Keyboard Shortcuts
Standard keyboard patterns:
| Component | Keys | Action |
|---|---|---|
| Button | Enter, Space | Activate |
| Link | Enter | Navigate |
| Checkbox | Space | Toggle |
| Radio | Arrow keys | Select option |
| Tabs | Arrow keys | Switch tab |
| Menu | Arrow keys, Enter | Navigate, select |
| Modal | Escape | Close |
| Dropdown | Escape | Close |
3.3 Roving Tabindex Pattern
For composite widgets (tabs, menus, toolbars):
// stimulus_controller: roving_tabindex_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["item"]
connect() {
this.currentIndex = 0
this.updateTabindex()
}
next(event) {
event.preventDefault()
this.currentIndex = (this.currentIndex + 1) % this.itemTargets.length
this.focusCurrent()
}
previous(event) {
event.preventDefault()
this.currentIndex = (this.currentIndex - 1 + this.itemTargets.length) % this.itemTargets.length
this.focusCurrent()
}
updateTabindex() {
this.itemTargets.forEach((item, index) => {
item.setAttribute("tabindex", index === this.currentIndex ? "0" : "-1")
})
}
focusCurrent() {
this.updateTabindex()
this.itemTargets[this.currentIndex].focus()
}
}
<%# Usage %>
<div role="tablist"
data-controller="roving-tabindex"
data-action="keydown.right->roving-tabindex#next keydown.left->roving-tabindex#previous">
<button role="tab" data-roving-tabindex-target="item" tabindex="0">Tab 1</button>
<button role="tab" data-roving-tabindex-target="item" tabindex="-1">Tab 2</button>
<button role="tab" data-roving-tabindex-target="item" tabindex="-1">Tab 3</button>
</div>
3.4 Focus Trapping for Modals
// stimulus_controller: focus_trap_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container"]
connect() {
this.focusableElements = this.containerTarget.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
this.firstFocusable = this.focusableElements[0]
this.lastFocusable = this.focusableElements[this.focusableElements.length - 1]
// Store previous focus
this.previousFocus = document.activeElement
// Focus first element
this.firstFocusable?.focus()
}
disconnect() {
// Restore focus
this.previousFocus?.focus()
}
trapFocus(event) {
if (event.key !== "Tab") return
if (event.shiftKey) {
if (document.activeElement === this.firstFocusable) {
event.preventDefault()
this.lastFocusable.focus()
}
} else {
if (document.activeElement === this.lastFocusable) {
event.preventDefault()
this.firstFocusable.focus()
}
}
}
}
4. Focus Management
4.1 Visible Focus Indicators
TailAdmin-compatible focus styles:
<%# Default focus ring %>
<button class="focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none">
Click Me
</button>
<%# High contrast focus for dark backgrounds %>
<button class="focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-boxdark">
Dark Button
</button>
<%# Custom focus visible (keyboard only) %>
<a href="#" class="focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none">
Link
</a>
<%# Focus within for parent highlighting %>
<div class="focus-within:ring-2 focus-within:ring-primary">
<input type="text" class="focus:outline-none">
</div>
4.2 Skip Navigation Links
<%# At the very top of the page %>
<a href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4
focus:z-50 focus:bg-primary focus:text-white focus:px-4 focus:py-2
focus:rounded">
Skip to main content
</a>
<header>...</header>
<nav>...</nav>
<main id="main-content" tabindex="-1">
<%# Main content %>
</main>
4.3 Focus After Dynamic Actions
// After deleting an item, focus the next item or container
deleteItem(event) {
const item = event.target.closest("[data-item]")
const nextItem = item.nextElementSibling || item.previousElementSibling
item.remove()
if (nextItem) {
nextItem.focus()
} else {
this.containerTarget.focus()
}
}
// After closing modal, return focus to trigger
closeModal() {
this.element.hidden = true
this.triggerElement?.focus()
}
// After form submission success
formSubmitSuccess() {
// Announce success
this.announceTarget.textContent = "Form submitted successfully"
// Focus confirmation or next action
this.confirmationTarget.focus()
}
5. Screen Reader Considerations
5.1 Visually Hidden Content
<%# Screen reader only text %>
<span class="sr-only">Additional context for screen readers</span>
<%# Tailwind sr-only class %>
<%# .sr-only { position: absolute; width: 1px; height: 1px; ... } %>
<%# Icon with accessible label %>
<button aria-label="Close">
<svg class="h-5 w-5" aria-hidden="true">...</svg>
</button>
<%# Or with visually hidden text %>
<button>
<svg class="h-5 w-5" aria-hidden="true">...</svg>
<span class="sr-only">Close</span>
</button>
5.2 Hiding Decorative Content
<%# Decorative images %>
<img src="decoration.svg" alt="" aria-hidden="true">
<%# Decorative icons %>
<svg aria-hidden="true" focusable="false">...</svg>
<%# Duplicate content %>
<a href="/profile">
<img src="avatar.jpg" alt=""> <%# alt="" because link text provides context %>
<span>View Profile</span>
</a>
<%# Presentational elements %>
<div role="presentation">...</div>
<hr aria-hidden="true">
5.3 Meaningful Announcements
<%# Page title changes %>
<title><%= [@page_title, "MyApp"].compact.join(" | ") %></title>
<%# Loading states %>
<div aria-busy="true" aria-describedby="loading-message">
<span id="loading-message" class="sr-only">Loading content...</span>
Loading...
</div>
<%# Form validation summary %>
<div role="alert" aria-live="assertive">
<h2>Please fix the following errors:</h2>
<ul>
<% @errors.each do |error| %>
<li><a href="#<%= error.field %>"><%= error.message %></a></li>
<% end %>
</ul>
</div>
<%# Dynamic count updates %>
<span aria-live="polite" aria-atomic="true">
<%= pluralize(@cart.items.count, "item") %> in cart
</span>
6. Color and Contrast
6.1 WCAG Contrast Requirements
| Content Type | Minimum Ratio | Example |
|---|---|---|
| Normal text (<18px) | 4.5:1 | Body text |
| Large text (>18px or 14px bold) | 3:1 | Headings |
| UI components | 3:1 | Buttons, inputs, icons |
| Graphical objects | 3:1 | Charts, diagrams |
6.2 TailAdmin Color Contrast Analysis
Passing Combinations (WCAG AA):
<%# Text on light backgrounds %>
<p class="text-black bg-white"> <%# 21:1 - Excellent %>
<p class="text-bodydark bg-white"> <%# ~7:1 - Pass %>
<p class="text-primary bg-white"> <%# ~4.7:1 - Pass %>
<%# Text on dark backgrounds %>
<p class="text-white bg-boxdark"> <%# ~12:1 - Excellent %>
<p class="text-bodydark1 bg-boxdark"> <%# ~5:1 - Pass %>
<%# Error states %>
<p class="text-danger bg-white"> <%# ~4.5:1 - Pass %>
<%# Verify custom colors %>
<%# Use: https://webaim.org/resources/contrastchecker/ %>
6.3 Don't Rely on Color Alone
<%# BAD: Color only indicates status %>
<span class="text-success">Approved</span>
<span class="text-danger">Rejected</span>
<%# GOOD: Icon + text + color %>
<span class="text-success flex items-center gap-2">
<svg aria-hidden="true"><%# checkmark icon %></svg>
Approved
</span>
<span class="text-danger flex items-center gap-2">
<svg aria-hidden="true"><%# X icon %></svg>
Rejected
</span>
<%# GOOD: Badges with pattern %>
<span class="bg-success/10 text-success border border-success px-2 py-1 rounded">
Approved
</span>
7. Testing Approaches
7.1 Automated Testing with axe-core
# spec/support/accessibility_helpers.rb
require "axe-rspec"
RSpec.configure do |config|
config.include AxeRspec
end
# Usage in system specs
RSpec.describe "Dashboard", type: :system do
it "is accessible" do
visit dashboard_path
expect(page).to be_axe_clean
end
it "is accessible with specific rules" do
visit dashboard_path
expect(page).to be_axe_clean.according_to(:wcag2aa)
end
it "is accessible excluding known issues" do
visit dashboard_path
expect(page).to be_axe_clean.excluding("color-contrast")
end
end
7.2 Manual Testing Checklist
Keyboard Testing:
- Tab through entire page - logical order?
- Can access all interactive elements?
- Focus visible at all times?
- Can escape modals with Escape key?
- Arrow keys work in menus/tabs?
Screen Reader Testing:
- Page title announced on load
- Headings create logical outline
- Images have appropriate alt text
- Form labels announced with inputs
- Error messages announced
- Dynamic updates announced (live regions)
Visual Testing:
- 200% zoom - content still usable?
- High contrast mode - content visible?
- Forced colors mode - UI intact?
7.3 Tools
| Tool | Purpose |
|---|---|
| axe DevTools (browser) | Automated accessibility testing |
| WAVE | Visual accessibility evaluation |
| Lighthouse | Performance + accessibility audit |
| NVDA/VoiceOver | Screen reader testing |
| Colour Contrast Analyser | Manual contrast checking |
8. Rails/ViewComponent Patterns
8.1 Accessible ViewComponent Base
# app/components/accessible_component.rb
class AccessibleComponent < ViewComponent::Base
# Common accessibility helpers
def unique_id(prefix = "component")
@unique_id ||= "#{prefix}-#{SecureRandom.hex(4)}"
end
def describedby_id
"#{unique_id}-description"
end
def labelledby_id
"#{unique_id}-label"
end
def error_id
"#{unique_id}-error"
end
def aria_attributes(options = {})
attrs = {}
attrs["aria-label"] = options[:label] if options[:label]
attrs["aria-labelledby"] = options[:labelledby] if options[:labelledby]
attrs["aria-describedby"] = options[:describedby] if options[:describedby]
attrs["aria-expanded"] = options[:expanded] if options.key?(:expanded)
attrs["aria-controls"] = options[:controls] if options[:controls]
attrs["aria-current"] = options[:current] if options[:current]
attrs["aria-invalid"] = options[:invalid] if options[:invalid]
attrs
end
end
8.2 Accessible Form Input Component
# app/components/form_input_component.rb
class FormInputComponent < AccessibleComponent
def initialize(form:, attribute:, label:, hint: nil, required: false)
@form = form
@attribute = attribute
@label = label
@hint = hint
@required = required
end
def has_error?
@form.object.errors[@attribute].any?
end
def error_message
@form.object.errors[@attribute].first
end
def input_attributes
attrs = {
"aria-describedby": [@hint ? describedby_id : nil, has_error? ? error_id : nil].compact.join(" ").presence,
"aria-required": @required,
"aria-invalid": has_error?
}
attrs["aria-errormessage"] = error_id if has_error?
attrs.compact
end
end
<%# app/components/form_input_component.html.erb %>
<div class="mb-4">
<%= @form.label @attribute, @label, class: "block text-sm font-medium text-black dark:text-white" %>
<% if @hint %>
<p id="<%= describedby_id %>" class="text-sm text-bodydark mt-1">
<%= @hint %>
</p>
<% end %>
<%= @form.text_field @attribute,
class: "mt-1 block w-full rounded border-stroke dark:border-strokedark
bg-transparent px-4 py-2 text-black dark:text-white
focus:border-primary focus:ring-primary
#{has_error? ? 'border-danger' : ''}",
**input_attributes %>
<% if has_error? %>
<p id="<%= error_id %>" class="mt-1 text-sm text-danger" role="alert">
<%= error_message %>
</p>
<% end %>
</div>
8.3 Accessible Modal Component
# app/components/modal_component.rb
class ModalComponent < AccessibleComponent
renders_one :trigger
renders_one :body
def initialize(title:)
@title = title
end
end
<%# app/components/modal_component.html.erb %>
<div data-controller="modal">
<%= trigger %>
<div data-modal-target="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="<%= labelledby_id %>"
class="fixed inset-0 z-50 hidden"
data-action="keydown.escape->modal#close">
<%# Backdrop %>
<div class="fixed inset-0 bg-black/50"
data-action="click->modal#close"
aria-hidden="true"></div>
<%# Dialog %>
<div class="fixed inset-0 flex items-center justify-center p-4"
data-controller="focus-trap"
data-focus-trap-target="container"
data-action="keydown->focus-trap#trapFocus">
<div class="bg-white dark:bg-boxdark rounded-lg shadow-xl max-w-md w-full p-6">
<h2 id="<%= labelledby_id %>" class="text-xl font-semibold text-black dark:text-white">
<%= @title %>
</h2>
<button data-action="click->modal#close"
class="absolute top-4 right-4"
aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<%= body %>
</div>
</div>
</div>
</div>
9. Quick Reference Checklist
Before shipping any UI component, verify:
Structure:
- Semantic HTML used (headings, lists, tables, nav, main, etc.)
- Heading hierarchy is logical (h1 > h2 > h3)
- Language declared on html element
Keyboard:
- All interactive elements focusable
- Focus order is logical
- Focus indicator visible
- No keyboard traps
Screen Readers:
- Images have alt text (or alt="" if decorative)
- Form inputs have labels
- ARIA used correctly (or not at all)
- Dynamic content announced
Visual:
- Color contrast meets 4.5:1 (text) or 3:1 (UI)
- Information not conveyed by color alone
- Works at 200% zoom
- Focus indicators have 3:1 contrast
Forms:
- Labels associated with inputs
- Required fields indicated
- Errors identified and described
- Error messages linked to inputs
Interactive:
- Buttons are buttons, links are links
- State communicated (expanded, selected, pressed)
- Loading states announced
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?