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
- Every page has
<html lang="en">, one<main id="main-content">, one<h1>, and headings in order (no jumping fromh1toh4). - Every input has a visible
<label for=...>. A placeholder is NOT a label. - Every button and link has a name a screen reader can say. An icon-only button needs
aria-label. - Use real
<button>and<a href>. Never put a click handler on a<div>. - Never remove the focus outline. Text contrast at least 4.5:1.
- Modals: focus goes in, Tab stays in, Escape closes, focus goes back to the opener.
- Status changes ("Saved", "Working…") go in a hidden
aria-liveregion. - 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-visiblering (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"andaria-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)
<%# 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 ownaria-labelif there is more than one. tabindex="-1"on<main>lets the skip link move focus there. Thefocus:outline-noneis safe ONLY on this element, because it is not interactive.
Layer 2 — Forms
<%# 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. requiredon the field, not only a red asterisk. Colour alone is never the signal.
Layer 3 — Buttons, links and icons
<%# 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-expandedin the Stimulus controller when the panel opens and closes.
Layer 4 — Focus and contrast (Tailwind)
/* Never ship this: */
button:focus { outline: none; } /* keyboard users lose their place */
<%# 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.
<%# 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>
// 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.
<%# 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>
// 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 })
}
// 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:
<%# app/views/invoices/update.turbo_stream.erb %>
<%= turbo_stream.update "live-region", "Invoice saved." %>
Layer 7 — Tables
<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:
# 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-livedoes 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 withspeechSynthesisor 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.
tabindexgreater than 0 breaks tab order. Use only0(make focusable) or-1(focusable by script only).display: noneandhiddenremove content from screen readers too. To hide text visually but keep it spoken, use Tailwind'ssr-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
- Start with the layout (Layer 1) and the live region (Layer 6). They cover every page.
- Walk each form through Layer 2, then each icon button through Layer 3.
- Replace any custom modal with the
<dialog>controller in Layer 5. - Search the app for
outline-none,text-gray-400and<divwithdata-actionand fix each hit. - Add each new screen's path to the axe spec in Layer 8 when you build it.