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:

  1. <html lang="en"> (or the appropriate BCP-47 tag)
  2. A skip-to-content link as the first focusable element
  3. Landmark regions: <header role="banner">, <main>, <footer role="contentinfo">
  4. An ARIA live region for htmx swap announcements
  5. 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:

Rust
use autumn_web::form::{
    text_input, password_input, textarea_input,
    required_text_input, aria_live_region, skip_link,
};

text_input

Rust
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 matching id="{field}" on the input
  • Inline error message (wrapped in role="alert") when the changeset has a validation error for that field
  • aria-invalid="true" and aria-describedby pointing at the error element
Rust
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.

Rust
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.

Rust
html! {
    (required_text_input(&changeset, "email", "Email address"))
}

textarea_input

Renders a labelled <textarea> with the same error-linking pattern.

Rust
html! {
    (textarea_input(&changeset, "body", "Post body"))
}

Renders a visually hidden "Skip to …" anchor that becomes visible on keyboard focus. Place it as the first element inside <body>.

Rust
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.

Rust
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:

  1. 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>
    
  2. 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>
    
  3. Clear the region after a short delay by including an empty update in the next response, or use a small JavaScript snippet:

    Js
    document.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:

Html
<!-- response HTML -->
<main id="main-content" tabindex="-1">
  <h1 autofocus>Search results</h1>
  ...
</main>

Or trigger focus programmatically with the htmx:afterSwap event:

Js
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:

ForegroundBackgroundRatioUsage
gray-900gray-10016:1Body text on page
gray-700white9.5:1Secondary nav links
whiteorange-5003.1:1CTA buttons (large text)
orange-600white4.7:1Inline links
gray-400white2.6:1Avoid — placeholder/hint text only, never body text

Warning: text-gray-400 on white falls below 4.5:1. Use it only for supplemental placeholder text. For any text that carries meaning, use text-gray-600 (5.9:1) or darker.

To verify contrast ratios during development:

Shell
# 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-visible ring)
  • [ ] 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 alt text; purely decorative images use alt=""
  • [ ] All form fields have a visible <label> (not just placeholder)

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

Shell
# 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 IDSeverityWhat it checks
html-has-langCritical<html> element has a non-empty lang attribute
bypassSeriousFirst focusable element is a skip link to #main
landmark-one-mainSeriousPage contains exactly one <main> element
image-altCriticalEvery <img> has an alt attribute (may be empty)
labelCriticalEvery <input> (non-hidden) has an associated <label>
button-nameSeriousEvery <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:

Yaml
# 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:

Rust
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:

Rust
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:

Json
{
  "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.


Further reading