Autumn is designed to produce WCAG 2.1 AA-compliant HTML by default. This
guide explains the built-in helpers, the patterns recommended for htmx-driven
pages, and how to integrate autumn check --a11y into your CI pipeline.
Quick-start checklist
Every page served by an Autumn app should satisfy these five requirements:
<html lang="en">(or the appropriate BCP-47 tag)- A skip-to-content link as the first focusable element
- Landmark regions:
<header role="banner">,<main>,<footer role="contentinfo"> - An ARIA live region for htmx swap announcements
- All form controls have an associated
<label>
The scaffold generated by autumn new already includes items 1–4. Items 5 and
beyond are enforced by the form helpers described below.
Form helpers (autumn_web::form)
Import the helpers you need:
use autumn_web::form::{
text_input, password_input, textarea_input,
required_text_input, aria_live_region, skip_link,
};
text_input
pub fn text_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup
Renders a labelled <input type="text"> with:
<label for="{field}">linked via matchingid="{field}"on the input- Inline error message (wrapped in
role="alert") when the changeset has a validation error for that field aria-invalid="true"andaria-describedbypointing at the error element
html! {
(text_input(&changeset, "username", "Username"))
}
password_input
Identical to text_input but renders <input type="password"> and never
sets value=, preventing password leakage into the DOM.
html! {
(password_input(&changeset, "password", "Password"))
}
required_text_input
Like text_input but adds both the HTML required attribute and
aria-required="true" so assistive technology announces the field as
mandatory.
html! {
(required_text_input(&changeset, "email", "Email address"))
}
textarea_input
Renders a labelled <textarea> with the same error-linking pattern.
html! {
(textarea_input(&changeset, "body", "Post body"))
}
skip_link
Renders a visually hidden "Skip to …" anchor that becomes visible on keyboard
focus. Place it as the first element inside <body>.
html! {
(skip_link("#main-content", "Skip to main content"))
header role="banner" { ... }
main id="main-content" { ... }
}
The helper emits class="skip-link". The Autumn CSS template already ships the
matching Tailwind utilities (-top-full at rest, top-0 on :focus) in
input.css.
aria_live_region
Renders a <div role="status" aria-live="polite" aria-atomic="true"> that
screen readers monitor for updates. Keep the element present in the DOM at all
times and update its text content via an htmx out-of-band swap — this avoids
focus loss.
html! {
(aria_live_region("htmx-status", "")) // empty at page load
}
htmx patterns for accessible interactivity
Announcing dynamic updates without moving focus
When htmx swaps content into the page, keyboard and screen-reader users lose context if focus jumps unexpectedly. The recommended pattern:
-
Add a persistent live region near the top of
<body>:Html<div id="htmx-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div> -
In every htmx response that changes meaningful content, include an out-of-band update to announce the change:
Html<!-- primary swap target --> <div id="post-list"> ... </div> <!-- screen-reader announcement (oob) --> <div id="htmx-status" hx-swap-oob="true">Post submitted.</div> -
Clear the region after a short delay by including an empty update in the next response, or use a small JavaScript snippet:
Jsdocument.body.addEventListener('htmx:afterSwap', () => { setTimeout(() => { const r = document.getElementById('htmx-status'); if (r) r.textContent = ''; }, 2000); });
The Autumn CSRF script (autumn-htmx-csrf.js) is already loaded from a
separate file so pages can use script-src 'self' in their CSP headers —
no inline event listener code is needed in the HTML.
Managing focus after htmx navigation
For operations that replace the entire <main> region (e.g. pagination, form
submission), explicitly move focus to a heading or the main landmark so keyboard
users know what changed:
<!-- response HTML -->
<main id="main-content" tabindex="-1">
<h1 autofocus>Search results</h1>
...
</main>
Or trigger focus programmatically with the htmx:afterSwap event:
document.body.addEventListener('htmx:afterSwap', e => {
const heading = e.detail.target.querySelector('h1, h2, [data-focus]');
if (heading) heading.focus({ preventScroll: true });
});
Color contrast with Tailwind
Autumn's generated Tailwind config uses the default Tailwind color palette. The following combinations used in the scaffold templates meet the WCAG 2.1 AA 4.5:1 contrast ratio for normal text and 3:1 for large text and UI components:
| Foreground | Background | Ratio | Usage |
|---|---|---|---|
gray-900 | gray-100 | 16:1 | Body text on page |
gray-700 | white | 9.5:1 | Secondary nav links |
white | orange-500 | 3.1:1 | CTA buttons (large text) |
orange-600 | white | 4.7:1 | Inline links |
gray-400 | white | 2.6:1 | Avoid — placeholder/hint text only, never body text |
Warning:
text-gray-400onwhitefalls below 4.5:1. Use it only for supplemental placeholder text. For any text that carries meaning, usetext-gray-600(5.9:1) or darker.
To verify contrast ratios during development:
# Using the axe browser extension (Chrome/Firefox) on the dev server
autumn dev
# Then open DevTools → axe → Analyse
Or run the Autumn a11y checker (see below) against the rendered HTML.
Keyboard-only navigation testing checklist
Perform this manual checklist before shipping any new page or component:
- [ ] Tab through the page from the skip link — every interactive element is reachable in a logical order
- [ ] Skip link appears visibly on first Tab keypress and navigates to
#main-content - [ ] All buttons and links have a visible focus indicator (
:focus-visiblering) - [ ] Dropdown menus and modals trap focus correctly (
tabindex="-1"on container + manual focus management) - [ ] Form submission errors are announced by the screen reader without page
reload (use
role="alert"on error summaries) - [ ] htmx-powered interactions do not silently replace content — the live region announces the change
- [ ] No keyboard trap: pressing Escape or Tab always allows leaving a widget
- [ ] Images have meaningful
alttext; purely decorative images usealt="" - [ ] All form fields have a visible
<label>(not justplaceholder)
autumn check --a11y
The CLI ships a static accessibility linter that scans HTML for common WCAG violations. It runs entirely in Rust — no Node.js or browser dependency.
Usage
# Check a running development server
autumn check --a11y --url http://localhost:8080
# Check pre-rendered HTML from a file or stdin
autumn check --a11y --html "$(cat rendered.html)"
# Only fail CI on Critical violations (Serious and Moderate are reported but
# exit 0)
autumn check --a11y --url http://localhost:8080 --critical-only
Rules
| Rule ID | Severity | What it checks |
|---|---|---|
html-has-lang | Critical | <html> element has a non-empty lang attribute |
bypass | Serious | First focusable element is a skip link to #main |
landmark-one-main | Serious | Page contains exactly one <main> element |
image-alt | Critical | Every <img> has an alt attribute (may be empty) |
label | Critical | Every <input> (non-hidden) has an associated <label> |
button-name | Serious | Every <button> has discernible text or aria-label |
CI integration
Add a job step that runs the checker against a preview deployment or a pre-rendered snapshot:
# GitHub Actions example
- name: Accessibility check
run: |
cargo install autumn-cli --locked
autumn check --a11y --url ${{ env.PREVIEW_URL }}
Exit code 0 means no Critical or Serious violations. Exit code 1 means at
least one violation was found (or --critical-only was set and a Critical
violation exists).
Programmatic use
autumn-cli exposes the checker as a library function for use in integration
tests:
use autumn_cli::check::{A11yCheckOptions, run_a11y_check, print_report};
#[test]
fn homepage_is_accessible() {
let html = /* render your Markup to String */;
let opts = A11yCheckOptions { html: Some(html), url: None, critical_only: false };
let violations = run_a11y_check(&opts).expect("checker failed");
assert!(violations.is_empty(), "a11y violations: {violations:?}");
}
Actuator endpoint: /actuator/a11y
When you implement ProvideActuatorState for your application state, you can
expose an accessibility posture endpoint:
impl ProvideActuatorState for AppState {
fn a11y_posture(&self) -> autumn_web::actuator::A11yPosture {
autumn_web::actuator::A11yPosture {
lang_set: true,
skip_link_present: true,
landmark_regions_present: true,
}
}
}
The endpoint is available at GET /actuator/a11y and returns:
{
"lang_set": true,
"skip_link_present": true,
"landmark_regions_present": true
}
is_compliant() returns true only when all three fields are true. You can
poll this endpoint in health checks or monitoring dashboards to confirm your
accessibility posture hasn't regressed.