| name | accessibility |
| description | Build WCAG 2.2 AA compliant interfaces — focus management, ARIA utilities, motion preferences, color contrast, screen reader support |
| user-invocable | true |
WCAG 2.2 AA Accessibility
You are an accessibility specialist who ensures all components meet WCAG 2.2 Level AA. The asset pipeline has accessibility built into its defaults — focus rings, motion preferences, color contrast, and ARIA state utilities are all provided out of the box.
Built-in Defaults
The CSS generator automatically includes these in the @layer base block:
Focus Rings (WCAG 2.4.7 — Focus Visible)
All interactive elements get a visible focus indicator on keyboard navigation:
:focus-visible {
outline: 2px solid var(--color-blue-500);
outline-offset: 2px;
}
No additional code needed. Every <button>, <a>, <input>, etc. will show a focus ring when tabbed to.
Reduced Motion (WCAG 2.2.2 — Pause, Stop, Hide / 2.3.1 — Three Flashes)
Animations are disabled when users prefer reduced motion:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Forced Colors (WCAG 1.4.1 — Use of Color)
Focus indicators work in Windows High Contrast mode:
@media (forced-colors: active) {
:focus-visible {
outline: 2px solid CanvasText;
}
}
Color Scheme
:root { color-scheme: light dark; }
Tells the browser to adapt form controls, scrollbars, and system colors to the user's preference.
Accessibility Utility Classes
Screen Reader Content (WCAG 1.1.1 — Non-text Content)
# Visually hidden but available to screen readers
span = Components::Elements::Span.new(class: "sr-only")
span << "Close dialog"
# Undo sr-only (e.g., on focus)
link = Components::Elements::A.new(href: "#main", class: "sr-only focus:not-sr-only")
link << "Skip to main content"
sr-only generates:
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0;
}
Focus Ring Utilities
ring Default focus ring (3px)
ring-0 No ring
ring-1 through ring-8 Ring width 1-8px
ring-inset Inset ring
outline-0 through outline-4 Outline width
outline-offset-0 through outline-offset-4 Outline offset
Example: focus-visible:ring-2 focus-visible:ring-blue-500
Touch Target Size (WCAG 2.5.8 — Target Size)
Use min-width/min-height utilities to ensure touch targets are at least 44x44px:
button = Components::Elements::Button.new("Tap me", type: "button")
button.add_class("min-w-11 min-h-11") # 2.75rem = 44px
ClassBuilder Accessibility Methods
The ClassBuilder has dedicated methods for accessibility-related states:
Focus Visible (WCAG 2.4.7)
classes = Components::CSS::ClassBuilder.new
.base("btn", "btn-primary")
.focus_visible("ring-2", "ring-blue-500", "outline-none")
.build
Generates: focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none
CSS output: .focus-visible\:ring-2:focus-visible { box-shadow: 0 0 0 2px ...; }
Motion Preferences (WCAG 2.2.2 / 2.3.1)
classes = Components::CSS::ClassBuilder.new
.base("transition-transform")
.motion_safe("hover:scale-105") # Only animate if user allows motion
.motion_reduce("hover:shadow-lg") # Alternative non-motion effect
.build
CSS output wraps in @media (prefers-reduced-motion: no-preference) and @media (prefers-reduced-motion: reduce).
ARIA State Styling
Style elements based on their ARIA state — no JavaScript needed for visual changes:
# Accordion item
classes = Components::CSS::ClassBuilder.new
.base("accordion-trigger", "p-4", "border-b")
.aria_expanded("bg-blue-50", "font-semibold") # When expanded
.build
# Tab
classes = Components::CSS::ClassBuilder.new
.base("tab", "px-4", "py-2")
.aria_selected("border-b-2", "border-blue-500", "text-blue-600")
.build
# Checkbox label
classes = Components::CSS::ClassBuilder.new
.base("checkbox-label")
.aria_checked("text-green-600")
.build
# Disabled element
classes = Components::CSS::ClassBuilder.new
.base("btn")
.aria_disabled("opacity-50", "cursor-not-allowed")
.build
CSS output uses attribute selectors:
.aria-expanded\:bg-blue-50[aria-expanded="true"] { background-color: ...; }
.aria-selected\:border-b-2[aria-selected="true"] { border-bottom-width: 2px; }
Form Validation States
classes = Components::CSS::ClassBuilder.new
.base("input", "border", "rounded")
.invalid("border-red-500", "text-red-900")
.valid("border-green-500")
.build
Additional Modifier Prefixes
Beyond ClassBuilder methods, these modifiers work with any utility class:
| Prefix | CSS Selector | WCAG Criterion |
|---|
focus-visible: | :focus-visible | 2.4.7 Focus Visible |
focus-within: | :focus-within | 2.4.7 (container) |
motion-safe: | @media (prefers-reduced-motion: no-preference) | 2.2.2, 2.3.1 |
motion-reduce: | @media (prefers-reduced-motion: reduce) | 2.2.2, 2.3.1 |
contrast-more: | @media (prefers-contrast: more) | 1.4.6 Enhanced Contrast |
forced-colors: | @media (forced-colors: active) | 1.4.1 Use of Color |
aria-expanded: | [aria-expanded="true"] | 4.1.2 Name, Role, Value |
aria-selected: | [aria-selected="true"] | 4.1.2 |
aria-checked: | [aria-checked="true"] | 4.1.2 |
aria-disabled: | [aria-disabled="true"] | 4.1.2 |
aria-hidden: | [aria-hidden="true"] | 4.1.2 |
aria-pressed: | [aria-pressed="true"] | 4.1.2 |
aria-busy: | [aria-busy="true"] | 4.1.2 |
required: | :required | 3.3.2 Labels or Instructions |
invalid: | :invalid | 3.3.1 Error Identification |
valid: | :valid | 3.3.1 |
checked: | :checked | 4.1.2 |
indeterminate: | :indeterminate | 4.1.2 |
read-only: | :read-only | 3.3.2 |
placeholder-shown: | :placeholder-shown | 3.3.2 |
pointer-coarse: | @media (pointer: coarse) | 2.5.5 Target Size |
pointer-fine: | @media (pointer: fine) | 2.5.5 |
inert: | :is([inert], [inert] *) | 2.4.7 |
open: | :is([open], details[open]) | 4.1.2 |
Element Validation
Element classes enforce accessibility-relevant attributes at construction time:
Table Headers
# Th validates scope attribute
th = Components::Elements::Th.new(scope: "col") # Valid: row, col, rowgroup, colgroup
th << "Name"
# Invalid scope raises ArgumentError
Components::Elements::Th.new(scope: "invalid") # => ArgumentError
Form Labeling
# Label with for attribute
label = Components::Elements::Label.new("Email", for: "email-input")
input = Components::Elements::Input.email("email")
input.set_attribute("id", "email-input")
input.set_attribute("aria-describedby", "email-help")
help = Components::Elements::Span.new(id: "email-help", class: "sr-only")
help << "Enter your email address"
Images
# Img validates loading and decoding
img = Components::Elements::Img.new(
src: "/photo.jpg",
alt: "A sunset over mountains", # Always provide alt text
loading: "lazy", # Valid: lazy, eager
decoding: "async" # Valid: sync, async, auto
)
# Decorative images
img = Components::Elements::Img.new(src: "/decoration.svg", alt: "", role: "presentation")
Links
# A validates target and rel
link = Components::Elements::A.new(
href: "https://example.com",
target: "_blank",
rel: "noopener noreferrer" # Security + accessibility for external links
)
Form Validation
# Form validates method and enctype
form = Components::Elements::Form.new(method: "POST", action: "/submit")
# Input validates type-specific attributes
input = Components::Elements::Input.number("quantity")
input.set_attribute("min", "1")
input.set_attribute("max", "99")
input.set_attribute("required", "true")
input.set_attribute("aria-label", "Quantity")
Accessible Component Patterns
Skip Navigation
skip = Components::Elements::A.new(href: "#main-content", class: "sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:p-4 focus:bg-white focus:rounded")
skip << "Skip to main content"
# ... header/nav ...
main = Components::Elements::Main.new(id: "main-content")
Landmark Regions
# Use semantic elements — they map to ARIA landmarks automatically
Components::Elements::Header.new # => banner
Components::Elements::Nav.new # => navigation
Components::Elements::Main.new # => main
Components::Elements::Aside.new # => complementary
Components::Elements::Footer.new # => contentinfo
# Add aria-label to distinguish multiple navs
Components::Elements::Nav.new(class: "breadcrumb").tap { |n| n.set_attribute("aria-label", "Breadcrumb") }
Components::Elements::Nav.new(class: "main-nav").tap { |n| n.set_attribute("aria-label", "Main navigation") }
Accessible Accordion
# Uses Details/Summary — native keyboard accessible
details = Components::Elements::Details.new(class: "accordion-item")
summary = Components::Elements::Summary.new(class: "accordion-trigger p-4 cursor-pointer")