---
title: Accessible Views (Keyboard + WCAG 2.1 AA)
seo_title: "Accessible Rails Views (WCAG 2.1 AA)"
slug: accessible-views-wcag-aa
category: Design
summary: The rules for building Rails views that work by keyboard and with a screen reader and pass WCAG 2.1 AA — landmarks, labels, button names, focus, contrast, modals, form errors, live status messages — plus a one-line axe check that stops regressions.
tags: [accessibility, a11y, wcag, keyboard, screen-reader, aria, forms, modals, axe, ux-default]
status: stable
visibility: public
source_project: LlamaBot chat + llama_bot_rails (ticket LP-34, 0.7.20)
layers: [view, stimulus_js]
related:
  - title: Color, Icons, and Contrast
    url: /cookbook/color-icons-and-contrast
    summary: The contrast floors (4.5:1 text, 3:1 large text and UI parts) in depth, and why colour must never be the only signal.
  - title: Focus Jumping in Inline-Editable Tables
    url: /cookbook/focus-restoration-after-row-replace
    summary: How to keep keyboard focus where the user left it when Turbo replaces a row.
  - title: Async Action Feedback
    url: /cookbook/async-action-feedback
    summary: The saving/saved indicator. Pair it with the live region below so a screen reader hears it too.
---

# Accessible Views (Keyboard + WCAG 2.1 AA)

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

Universities, governments and large companies ask one question before they buy software:
*"Does it conform to WCAG 2.1 AA, and can every function be done by keyboard alone?"*
This guide is the checklist for answering yes. Build every new screen this way from the
start. Retrofitting an app afterwards costs far more.

> **When to use:** every view you write. **When not to:** never skip it. Internal admin
> screens are audited too.

---

## The 80/20 in one breath

1. Every page has `<html lang="en">`, one `<main id="main-content">`, one `<h1>`, and
   headings in order (no jumping from `h1` to `h4`).
2. Every input has a visible `<label for=...>`. A placeholder is NOT a label.
3. Every button and link has a name a screen reader can say. An icon-only button needs
   `aria-label`.
4. Use real `<button>` and `<a href>`. Never put a click handler on a `<div>`.
5. Never remove the focus outline. Text contrast at least **4.5:1**.
6. Modals: focus goes in, Tab stays in, **Escape closes**, focus goes back to the opener.
7. Status changes ("Saved", "Working…") go in a hidden `aria-live` region.
8. Add one axe spec per page so a later edit cannot break any of the above.

---

## Layer 0 — What the platform already does for you

On LlamaPress boxes with the `llama_bot_rails` gem at **0.7.20 or later**, the gem
rewrites every full HTML page after it renders (`LlamaBotRails::AccessibilityInjection`):

- adds `lang="en"` to `<html>` if it is missing;
- adds a visible `:focus-visible` ring (2px indigo);
- adds a **"Skip to main content"** link that targets your `<main>` (or the first `<h1>`);
- marks a field that failed validation with `aria-invalid="true"` and
  `aria-describedby="error_explanation"`.

So: **do not add a second skip link**, and do give the page a `<main>`. If your layout
already has its own skip link with a class containing `skip-link`, the gem leaves it
alone. An app turns the whole thing off with
`config.llama_bot_rails.inject_accessibility = false` (do this only if the app ships its
own equivalent).

The gem cannot fix what is inside YOUR views: a missing label, an unnamed icon button, a
grey-on-white paragraph. That is the rest of this guide.

---

## Layer 1 — Page structure (the layout)

```erb
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html lang="en">
  <head>
    <title><%= content_for?(:title) ? "#{yield(:title)} | My App" : "My App" %></title>
    <%# ... %>
  </head>
  <body>
    <header>
      <nav aria-label="Main">
        <%# links %>
      </nav>
    </header>

    <main id="main-content" tabindex="-1" class="focus:outline-none">
      <%= yield %>
    </main>

    <%= render "shared/live_region" %>
  </body>
</html>
```

- A unique `<title>` per page (`content_for :title, "Invoices"`). Screen readers say it
  first after every Turbo visit.
- One `<nav>` per menu, each with its own `aria-label` if there is more than one.
- `tabindex="-1"` on `<main>` lets the skip link move focus there. The
  `focus:outline-none` is safe ONLY on this element, because it is not interactive.

---

## Layer 2 — Forms

```erb
<%# app/views/invoices/_form.html.erb %>
<%= form_with model: invoice do |f| %>
  <% if invoice.errors.any? %>
    <div id="error_explanation" role="alert" tabindex="-1"
         class="rounded border border-red-300 bg-red-50 p-3 text-red-800">
      <h2 class="font-semibold"><%= pluralize(invoice.errors.count, "problem") %> to fix:</h2>
      <ul class="list-disc pl-5">
        <% invoice.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= f.label :amount, "Amount (USD)", class: "block text-sm font-medium text-gray-800" %>
    <%= f.number_field :amount, required: true, "aria-describedby": "amount_hint",
          class: "mt-1 block w-full rounded border-gray-500" %>
    <p id="amount_hint" class="text-sm text-gray-600">Whole dollars, no commas.</p>
  </div>

  <fieldset>
    <legend class="text-sm font-medium text-gray-800">Status</legend>
    <%= f.radio_button :status, "draft" %> <%= f.label :status_draft, "Draft" %>
    <%= f.radio_button :status, "sent" %>  <%= f.label :status_sent, "Sent" %>
  </fieldset>

  <%= f.submit "Save invoice" %>
<% end %>
```

- `role="alert"` makes the error summary speak as soon as the page renders it.
- Group radio buttons and checkboxes in `<fieldset>` + `<legend>`.
- Hints and inline errors connect to their input with `aria-describedby`.
- `required` on the field, not only a red asterisk. Colour alone is never the signal.

---

## Layer 3 — Buttons, links and icons

```erb
<%# Icon-only button: the icon is hidden, the name is on the button %>
<button type="button" aria-label="Delete invoice <%= invoice.number %>"
        data-action="invoice#destroy">
  <i class="fa-solid fa-trash" aria-hidden="true"></i>
</button>

<%# A toggle says whether it is open %>
<button type="button" aria-expanded="false" aria-controls="filters"
        data-action="disclosure#toggle">Filters</button>
<div id="filters" hidden>...</div>

<%# Link text makes sense out of context %>
<%= link_to "View invoice #{invoice.number}", invoice %>   <%# not "Click here" %>
```

- A `<div data-action="click->...">` is unreachable by Tab and silent to a screen
  reader. Use `<button type="button">`.
- An icon next to a visible word gets `aria-hidden="true"`, so it is not read twice.
- Update `aria-expanded` in the Stimulus controller when the panel opens and closes.

---

## Layer 4 — Focus and contrast (Tailwind)

```css
/* Never ship this: */
button:focus { outline: none; }            /* keyboard users lose their place */
```

```erb
<%# Do this when you style a custom control %>
<button class="rounded px-3 py-2 focus-visible:outline focus-visible:outline-2
               focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Save</button>
```

Contrast on a white background, normal-size text (needs 4.5:1):

| Tailwind class | Ratio | OK for body text? |
|---|---|---|
| `text-gray-400` | 2.5:1 | **No** |
| `text-gray-500` | 4.8:1 | Yes, just barely. Avoid for small text |
| `text-gray-600` | 7.6:1 | Yes |
| `text-indigo-600` | 6.3:1 | Yes |
| White on `bg-indigo-500` | 4.47:1 | **No** (just under). Use `bg-indigo-600` (6.3:1) |

Input borders and the focus ring need 3:1 against the background. `border-gray-300` (1.5:1)
and `border-gray-400` (2.5:1) both fail; `border-gray-500` (4.8:1) passes.

---

## Layer 5 — Modals and drawers (Stimulus)

Prefer the native `<dialog>` element. `showModal()` gives you the focus trap and Escape
for free.

```erb
<%# app/views/shared/_confirm_dialog.html.erb %>
<div data-controller="dialog">
  <button type="button" data-action="dialog#open">Archive project</button>
  <dialog data-dialog-target="dialog" aria-labelledby="archive_title"
          class="rounded-lg p-6 backdrop:bg-black/40">
    <h2 id="archive_title" class="text-lg font-semibold">Archive this project?</h2>
    <p class="text-gray-700">You can restore it later.</p>
    <div class="mt-4 flex gap-2">
      <button type="button" data-action="dialog#close">Cancel</button>
      <%= button_to "Archive", archive_project_path(project), method: :post %>
    </div>
  </dialog>
</div>
```

```js
// app/javascript/controllers/dialog_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["dialog"]

  open(event) {
    this.opener = event.currentTarget
    this.dialogTarget.showModal()          // traps Tab, Escape fires "close"
    this.dialogTarget.addEventListener("close", () => this.opener?.focus(), { once: true })
  }

  close() {
    this.dialogTarget.close()              // focus returns to the opener via the listener
  }
}
```

If you must build a custom drawer from `<div>`s: add `role="dialog"`,
`aria-modal="true"`, `aria-labelledby`, move focus inside on open, keep Tab inside, close
on Escape, and return focus to the button that opened it.

---

## Layer 6 — Status messages (the live region)

Some changes happen without a page load: "Saved", "3 results", "Upload finished". A
sighted user sees them. A screen reader user hears nothing unless you put the text in a
live region.

```erb
<%# app/views/shared/_live_region.html.erb (render once, in the layout) %>
<div id="live-region" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
```

```js
// app/javascript/lib/announce.js
export function announce(text) {
  const region = document.getElementById("live-region")
  if (!region) return
  region.textContent = ""                          // clear first, so a repeat is read again
  requestAnimationFrame(() => { region.textContent = text })
}
```

```js
// in any Stimulus controller
import { announce } from "lib/announce"
// ...after a save succeeds:
announce("Invoice saved.")
```

For a Turbo Stream, append the message straight into the region:

```erb
<%# app/views/invoices/update.turbo_stream.erb %>
<%= turbo_stream.update "live-region", "Invoice saved." %>
```

---

## Layer 7 — Tables

```erb
<table>
  <caption class="sr-only">Invoices, newest first</caption>
  <thead>
    <tr><th scope="col">Number</th><th scope="col">Client</th><th scope="col">Amount</th></tr>
  </thead>
  <tbody>
    <% @invoices.each do |invoice| %>
      <tr><th scope="row"><%= invoice.number %></th><td>...</td><td>...</td></tr>
    <% end %>
  </tbody>
</table>
```

A sortable column header is a `<button>` inside the `<th>`, and the `<th>` carries
`aria-sort="ascending"` or `"descending"`.

---

## Layer 8 — The automated check (stops regressions)

The `axe-core-rspec` gem (from Deque, the makers of axe) runs the WCAG rules inside a
system spec. If the gem is in the app's Gemfile:

```ruby
# spec/system/accessibility_spec.rb
require "rails_helper"
require "axe-rspec"

RSpec.describe "Accessibility", type: :system, js: true do
  before { sign_in create(:user) }

  %w[/ /invoices /invoices/new /users/edit].each do |path|
    it "#{path} has no WCAG 2.1 AA violations" do
      visit path
      expect(page).to be_axe_clean.according_to(:wcag2a, :wcag2aa, :wcag21a, :wcag21aa)
    end
  end
end
```

If the gem is not available on this box, run the same rules from the browser console
(paste axe from `https://cdn.jsdelivr.net/npm/axe-core@4.10.3/axe.min.js`, then
`await axe.run()` and read `violations`). Then do one **keyboard-only pass** by hand: put
the mouse away, press Tab through the page, and use every function. axe cannot find a
keyboard trap or a missing Escape.

---

## Gotchas (the hard-won stuff)

- **`aria-live` does NOT make the browser talk.** Only a screen reader the user turned on
  (VoiceOver, NVDA, JAWS) reads it. Users without one hear nothing and see nothing. Never
  "fix" accessibility with `speechSynthesis` or audio that plays by default. That is a
  bad experience for everyone else.
- **Do not install an "accessibility overlay" widget** (a toolbar script that promises
  instant compliance). Reviewers count them against you, and they do not make a page
  conform. Clean HTML does.
- **A placeholder is not a label.** It disappears on typing and usually fails contrast.
- **`tabindex` greater than 0 breaks tab order.** Use only `0` (make focusable) or `-1`
  (focusable by script only).
- **`display: none` and `hidden` remove content from screen readers too.** To hide text
  visually but keep it spoken, use Tailwind's `sr-only`.
- **The focus ring can read as 0px right after Tab** if it animates. Check it after the
  transition ends before you call it missing.
- **Turbo visits do not move focus.** After navigation, the screen reader may stay where
  it was. The skip link plus a unique `<title>` per page is the minimum. For in-page
  replacements, see the focus-restoration guide.
- **Disabled buttons cannot be focused,** so a screen reader user never learns why they
  are disabled. Keep the button enabled and show the reason on submit, or put the reason
  in visible text next to it.
- **A drag-and-drop or drawing tool needs a keyboard alternative** (Move up / Move down
  buttons, a select). If there is none, the honest answer to "can every function be done
  by keyboard?" is no.

---

## Files this pattern touches

```
app/views/layouts/application.html.erb
app/views/shared/_live_region.html.erb
app/views/shared/_confirm_dialog.html.erb
app/javascript/controllers/dialog_controller.js
app/javascript/lib/announce.js
spec/system/accessibility_spec.rb
```

## How to adapt to your schema

1. Start with the layout (Layer 1) and the live region (Layer 6). They cover every page.
2. Walk each form through Layer 2, then each icon button through Layer 3.
3. Replace any custom modal with the `<dialog>` controller in Layer 5.
4. Search the app for `outline-none`, `text-gray-400` and `<div` with `data-action` and
   fix each hit.
5. Add each new screen's path to the axe spec in Layer 8 when you build it.
