- 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:
```css
: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:
```css
@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:
```css
@media (forced-colors: active) {
:focus-visible {
outline: 2px solid CanvasText;
}
}
```
### Color Scheme
```css
: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)
```crystal
# 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:
```css
.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:
```crystal
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)
```crystal
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)
```crystal
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:
```crystal
# 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:
```css
.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
```crystal
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
```crystal
# 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
```crystal
# 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
```crystal
# 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
```crystal
# 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
```crystal
# 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
```crystal
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
```crystal
# 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
```crystal
# 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")
Ver en GitHub