{"slug":"accessible-views-wcag-aa","meta":{"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."}]},"body":"# Accessible Views (Keyboard + WCAG 2.1 AA)\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nUniversities, governments and large companies ask one question before they buy software:\n*\"Does it conform to WCAG 2.1 AA, and can every function be done by keyboard alone?\"*\nThis guide is the checklist for answering yes. Build every new screen this way from the\nstart. Retrofitting an app afterwards costs far more.\n\n\u003e **When to use:** every view you write. **When not to:** never skip it. Internal admin\n\u003e screens are audited too.\n\n---\n\n## The 80/20 in one breath\n\n1. Every page has `\u003chtml lang=\"en\"\u003e`, one `\u003cmain id=\"main-content\"\u003e`, one `\u003ch1\u003e`, and\n   headings in order (no jumping from `h1` to `h4`).\n2. Every input has a visible `\u003clabel for=...\u003e`. A placeholder is NOT a label.\n3. Every button and link has a name a screen reader can say. An icon-only button needs\n   `aria-label`.\n4. Use real `\u003cbutton\u003e` and `\u003ca href\u003e`. Never put a click handler on a `\u003cdiv\u003e`.\n5. Never remove the focus outline. Text contrast at least **4.5:1**.\n6. Modals: focus goes in, Tab stays in, **Escape closes**, focus goes back to the opener.\n7. Status changes (\"Saved\", \"Working…\") go in a hidden `aria-live` region.\n8. Add one axe spec per page so a later edit cannot break any of the above.\n\n---\n\n## Layer 0 — What the platform already does for you\n\nOn LlamaPress boxes with the `llama_bot_rails` gem at **0.7.20 or later**, the gem\nrewrites every full HTML page after it renders (`LlamaBotRails::AccessibilityInjection`):\n\n- adds `lang=\"en\"` to `\u003chtml\u003e` if it is missing;\n- adds a visible `:focus-visible` ring (2px indigo);\n- adds a **\"Skip to main content\"** link that targets your `\u003cmain\u003e` (or the first `\u003ch1\u003e`);\n- marks a field that failed validation with `aria-invalid=\"true\"` and\n  `aria-describedby=\"error_explanation\"`.\n\nSo: **do not add a second skip link**, and do give the page a `\u003cmain\u003e`. If your layout\nalready has its own skip link with a class containing `skip-link`, the gem leaves it\nalone. An app turns the whole thing off with\n`config.llama_bot_rails.inject_accessibility = false` (do this only if the app ships its\nown equivalent).\n\nThe gem cannot fix what is inside YOUR views: a missing label, an unnamed icon button, a\ngrey-on-white paragraph. That is the rest of this guide.\n\n---\n\n## Layer 1 — Page structure (the layout)\n\n```erb\n\u003c%# app/views/layouts/application.html.erb %\u003e\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"en\"\u003e\n  \u003chead\u003e\n    \u003ctitle\u003e\u003c%= content_for?(:title) ? \"#{yield(:title)} | My App\" : \"My App\" %\u003e\u003c/title\u003e\n    \u003c%# ... %\u003e\n  \u003c/head\u003e\n  \u003cbody\u003e\n    \u003cheader\u003e\n      \u003cnav aria-label=\"Main\"\u003e\n        \u003c%# links %\u003e\n      \u003c/nav\u003e\n    \u003c/header\u003e\n\n    \u003cmain id=\"main-content\" tabindex=\"-1\" class=\"focus:outline-none\"\u003e\n      \u003c%= yield %\u003e\n    \u003c/main\u003e\n\n    \u003c%= render \"shared/live_region\" %\u003e\n  \u003c/body\u003e\n\u003c/html\u003e\n```\n\n- A unique `\u003ctitle\u003e` per page (`content_for :title, \"Invoices\"`). Screen readers say it\n  first after every Turbo visit.\n- One `\u003cnav\u003e` per menu, each with its own `aria-label` if there is more than one.\n- `tabindex=\"-1\"` on `\u003cmain\u003e` lets the skip link move focus there. The\n  `focus:outline-none` is safe ONLY on this element, because it is not interactive.\n\n---\n\n## Layer 2 — Forms\n\n```erb\n\u003c%# app/views/invoices/_form.html.erb %\u003e\n\u003c%= form_with model: invoice do |f| %\u003e\n  \u003c% if invoice.errors.any? %\u003e\n    \u003cdiv id=\"error_explanation\" role=\"alert\" tabindex=\"-1\"\n         class=\"rounded border border-red-300 bg-red-50 p-3 text-red-800\"\u003e\n      \u003ch2 class=\"font-semibold\"\u003e\u003c%= pluralize(invoice.errors.count, \"problem\") %\u003e to fix:\u003c/h2\u003e\n      \u003cul class=\"list-disc pl-5\"\u003e\n        \u003c% invoice.errors.full_messages.each do |msg| %\u003e\u003cli\u003e\u003c%= msg %\u003e\u003c/li\u003e\u003c% end %\u003e\n      \u003c/ul\u003e\n    \u003c/div\u003e\n  \u003c% end %\u003e\n\n  \u003cdiv\u003e\n    \u003c%= f.label :amount, \"Amount (USD)\", class: \"block text-sm font-medium text-gray-800\" %\u003e\n    \u003c%= f.number_field :amount, required: true, \"aria-describedby\": \"amount_hint\",\n          class: \"mt-1 block w-full rounded border-gray-500\" %\u003e\n    \u003cp id=\"amount_hint\" class=\"text-sm text-gray-600\"\u003eWhole dollars, no commas.\u003c/p\u003e\n  \u003c/div\u003e\n\n  \u003cfieldset\u003e\n    \u003clegend class=\"text-sm font-medium text-gray-800\"\u003eStatus\u003c/legend\u003e\n    \u003c%= f.radio_button :status, \"draft\" %\u003e \u003c%= f.label :status_draft, \"Draft\" %\u003e\n    \u003c%= f.radio_button :status, \"sent\" %\u003e  \u003c%= f.label :status_sent, \"Sent\" %\u003e\n  \u003c/fieldset\u003e\n\n  \u003c%= f.submit \"Save invoice\" %\u003e\n\u003c% end %\u003e\n```\n\n- `role=\"alert\"` makes the error summary speak as soon as the page renders it.\n- Group radio buttons and checkboxes in `\u003cfieldset\u003e` + `\u003clegend\u003e`.\n- Hints and inline errors connect to their input with `aria-describedby`.\n- `required` on the field, not only a red asterisk. Colour alone is never the signal.\n\n---\n\n## Layer 3 — Buttons, links and icons\n\n```erb\n\u003c%# Icon-only button: the icon is hidden, the name is on the button %\u003e\n\u003cbutton type=\"button\" aria-label=\"Delete invoice \u003c%= invoice.number %\u003e\"\n        data-action=\"invoice#destroy\"\u003e\n  \u003ci class=\"fa-solid fa-trash\" aria-hidden=\"true\"\u003e\u003c/i\u003e\n\u003c/button\u003e\n\n\u003c%# A toggle says whether it is open %\u003e\n\u003cbutton type=\"button\" aria-expanded=\"false\" aria-controls=\"filters\"\n        data-action=\"disclosure#toggle\"\u003eFilters\u003c/button\u003e\n\u003cdiv id=\"filters\" hidden\u003e...\u003c/div\u003e\n\n\u003c%# Link text makes sense out of context %\u003e\n\u003c%= link_to \"View invoice #{invoice.number}\", invoice %\u003e   \u003c%# not \"Click here\" %\u003e\n```\n\n- A `\u003cdiv data-action=\"click-\u003e...\"\u003e` is unreachable by Tab and silent to a screen\n  reader. Use `\u003cbutton type=\"button\"\u003e`.\n- An icon next to a visible word gets `aria-hidden=\"true\"`, so it is not read twice.\n- Update `aria-expanded` in the Stimulus controller when the panel opens and closes.\n\n---\n\n## Layer 4 — Focus and contrast (Tailwind)\n\n```css\n/* Never ship this: */\nbutton:focus { outline: none; }            /* keyboard users lose their place */\n```\n\n```erb\n\u003c%# Do this when you style a custom control %\u003e\n\u003cbutton class=\"rounded px-3 py-2 focus-visible:outline focus-visible:outline-2\n               focus-visible:outline-offset-2 focus-visible:outline-indigo-600\"\u003eSave\u003c/button\u003e\n```\n\nContrast on a white background, normal-size text (needs 4.5:1):\n\n| Tailwind class | Ratio | OK for body text? |\n|---|---|---|\n| `text-gray-400` | 2.5:1 | **No** |\n| `text-gray-500` | 4.8:1 | Yes, just barely. Avoid for small text |\n| `text-gray-600` | 7.6:1 | Yes |\n| `text-indigo-600` | 6.3:1 | Yes |\n| White on `bg-indigo-500` | 4.47:1 | **No** (just under). Use `bg-indigo-600` (6.3:1) |\n\nInput borders and the focus ring need 3:1 against the background. `border-gray-300` (1.5:1)\nand `border-gray-400` (2.5:1) both fail; `border-gray-500` (4.8:1) passes.\n\n---\n\n## Layer 5 — Modals and drawers (Stimulus)\n\nPrefer the native `\u003cdialog\u003e` element. `showModal()` gives you the focus trap and Escape\nfor free.\n\n```erb\n\u003c%# app/views/shared/_confirm_dialog.html.erb %\u003e\n\u003cdiv data-controller=\"dialog\"\u003e\n  \u003cbutton type=\"button\" data-action=\"dialog#open\"\u003eArchive project\u003c/button\u003e\n  \u003cdialog data-dialog-target=\"dialog\" aria-labelledby=\"archive_title\"\n          class=\"rounded-lg p-6 backdrop:bg-black/40\"\u003e\n    \u003ch2 id=\"archive_title\" class=\"text-lg font-semibold\"\u003eArchive this project?\u003c/h2\u003e\n    \u003cp class=\"text-gray-700\"\u003eYou can restore it later.\u003c/p\u003e\n    \u003cdiv class=\"mt-4 flex gap-2\"\u003e\n      \u003cbutton type=\"button\" data-action=\"dialog#close\"\u003eCancel\u003c/button\u003e\n      \u003c%= button_to \"Archive\", archive_project_path(project), method: :post %\u003e\n    \u003c/div\u003e\n  \u003c/dialog\u003e\n\u003c/div\u003e\n```\n\n```js\n// app/javascript/controllers/dialog_controller.js\nimport { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"dialog\"]\n\n  open(event) {\n    this.opener = event.currentTarget\n    this.dialogTarget.showModal()          // traps Tab, Escape fires \"close\"\n    this.dialogTarget.addEventListener(\"close\", () =\u003e this.opener?.focus(), { once: true })\n  }\n\n  close() {\n    this.dialogTarget.close()              // focus returns to the opener via the listener\n  }\n}\n```\n\nIf you must build a custom drawer from `\u003cdiv\u003e`s: add `role=\"dialog\"`,\n`aria-modal=\"true\"`, `aria-labelledby`, move focus inside on open, keep Tab inside, close\non Escape, and return focus to the button that opened it.\n\n---\n\n## Layer 6 — Status messages (the live region)\n\nSome changes happen without a page load: \"Saved\", \"3 results\", \"Upload finished\". A\nsighted user sees them. A screen reader user hears nothing unless you put the text in a\nlive region.\n\n```erb\n\u003c%# app/views/shared/_live_region.html.erb (render once, in the layout) %\u003e\n\u003cdiv id=\"live-region\" class=\"sr-only\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\"\u003e\u003c/div\u003e\n```\n\n```js\n// app/javascript/lib/announce.js\nexport function announce(text) {\n  const region = document.getElementById(\"live-region\")\n  if (!region) return\n  region.textContent = \"\"                          // clear first, so a repeat is read again\n  requestAnimationFrame(() =\u003e { region.textContent = text })\n}\n```\n\n```js\n// in any Stimulus controller\nimport { announce } from \"lib/announce\"\n// ...after a save succeeds:\nannounce(\"Invoice saved.\")\n```\n\nFor a Turbo Stream, append the message straight into the region:\n\n```erb\n\u003c%# app/views/invoices/update.turbo_stream.erb %\u003e\n\u003c%= turbo_stream.update \"live-region\", \"Invoice saved.\" %\u003e\n```\n\n---\n\n## Layer 7 — Tables\n\n```erb\n\u003ctable\u003e\n  \u003ccaption class=\"sr-only\"\u003eInvoices, newest first\u003c/caption\u003e\n  \u003cthead\u003e\n    \u003ctr\u003e\u003cth scope=\"col\"\u003eNumber\u003c/th\u003e\u003cth scope=\"col\"\u003eClient\u003c/th\u003e\u003cth scope=\"col\"\u003eAmount\u003c/th\u003e\u003c/tr\u003e\n  \u003c/thead\u003e\n  \u003ctbody\u003e\n    \u003c% @invoices.each do |invoice| %\u003e\n      \u003ctr\u003e\u003cth scope=\"row\"\u003e\u003c%= invoice.number %\u003e\u003c/th\u003e\u003ctd\u003e...\u003c/td\u003e\u003ctd\u003e...\u003c/td\u003e\u003c/tr\u003e\n    \u003c% end %\u003e\n  \u003c/tbody\u003e\n\u003c/table\u003e\n```\n\nA sortable column header is a `\u003cbutton\u003e` inside the `\u003cth\u003e`, and the `\u003cth\u003e` carries\n`aria-sort=\"ascending\"` or `\"descending\"`.\n\n---\n\n## Layer 8 — The automated check (stops regressions)\n\nThe `axe-core-rspec` gem (from Deque, the makers of axe) runs the WCAG rules inside a\nsystem spec. If the gem is in the app's Gemfile:\n\n```ruby\n# spec/system/accessibility_spec.rb\nrequire \"rails_helper\"\nrequire \"axe-rspec\"\n\nRSpec.describe \"Accessibility\", type: :system, js: true do\n  before { sign_in create(:user) }\n\n  %w[/ /invoices /invoices/new /users/edit].each do |path|\n    it \"#{path} has no WCAG 2.1 AA violations\" do\n      visit path\n      expect(page).to be_axe_clean.according_to(:wcag2a, :wcag2aa, :wcag21a, :wcag21aa)\n    end\n  end\nend\n```\n\nIf the gem is not available on this box, run the same rules from the browser console\n(paste axe from `https://cdn.jsdelivr.net/npm/axe-core@4.10.3/axe.min.js`, then\n`await axe.run()` and read `violations`). Then do one **keyboard-only pass** by hand: put\nthe mouse away, press Tab through the page, and use every function. axe cannot find a\nkeyboard trap or a missing Escape.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **`aria-live` does NOT make the browser talk.** Only a screen reader the user turned on\n  (VoiceOver, NVDA, JAWS) reads it. Users without one hear nothing and see nothing. Never\n  \"fix\" accessibility with `speechSynthesis` or audio that plays by default. That is a\n  bad experience for everyone else.\n- **Do not install an \"accessibility overlay\" widget** (a toolbar script that promises\n  instant compliance). Reviewers count them against you, and they do not make a page\n  conform. Clean HTML does.\n- **A placeholder is not a label.** It disappears on typing and usually fails contrast.\n- **`tabindex` greater than 0 breaks tab order.** Use only `0` (make focusable) or `-1`\n  (focusable by script only).\n- **`display: none` and `hidden` remove content from screen readers too.** To hide text\n  visually but keep it spoken, use Tailwind's `sr-only`.\n- **The focus ring can read as 0px right after Tab** if it animates. Check it after the\n  transition ends before you call it missing.\n- **Turbo visits do not move focus.** After navigation, the screen reader may stay where\n  it was. The skip link plus a unique `\u003ctitle\u003e` per page is the minimum. For in-page\n  replacements, see the focus-restoration guide.\n- **Disabled buttons cannot be focused,** so a screen reader user never learns why they\n  are disabled. Keep the button enabled and show the reason on submit, or put the reason\n  in visible text next to it.\n- **A drag-and-drop or drawing tool needs a keyboard alternative** (Move up / Move down\n  buttons, a select). If there is none, the honest answer to \"can every function be done\n  by keyboard?\" is no.\n\n---\n\n## Files this pattern touches\n\n```\napp/views/layouts/application.html.erb\napp/views/shared/_live_region.html.erb\napp/views/shared/_confirm_dialog.html.erb\napp/javascript/controllers/dialog_controller.js\napp/javascript/lib/announce.js\nspec/system/accessibility_spec.rb\n```\n\n## How to adapt to your schema\n\n1. Start with the layout (Layer 1) and the live region (Layer 6). They cover every page.\n2. Walk each form through Layer 2, then each icon button through Layer 3.\n3. Replace any custom modal with the `\u003cdialog\u003e` controller in Layer 5.\n4. Search the app for `outline-none`, `text-gray-400` and `\u003cdiv` with `data-action` and\n   fix each hit.\n5. Add each new screen's path to the axe spec in Layer 8 when you build it.\n"}