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,
    checkbox_input, number_input, date_input, datetime_input, select_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"))
}

checkbox_input

Renders a labelled <input type="checkbox"> for a bool field. Unchecked checkboxes are omitted from submitted form data by the browser, so the target field must be marked with #[serde(default)] to decode as false when absent (rather than pairing it with a hidden input sibling, which would cause duplicate-key errors on checked submissions).

Rust
html! {
    (checkbox_input(&changeset, "published", "Published"))
}

number_input

Renders a labelled <input type="number"> for i32/i64/f32/f64 fields. Pass step to control the HTML step attribute — Some("1") for integers, Some("0.01") or Some("any") for floats, None for the browser default.

Rust
html! {
    (number_input(&changeset, "quantity", "Quantity", Some("1")))
    (number_input(&changeset, "price", "Price", Some("0.01")))
}

date_input / datetime_input

Render <input type="date"> and <input type="datetime-local"> respectively. The current value is normalized to the shape each control requires (YYYY-MM-DD / YYYY-MM-DDTHH:MM[:SS[.f]], preserving seconds/fractional seconds when present), regardless of whether the underlying field serializes as a bare date or a full RFC 3339 timestamp.

Rust
html! {
    (date_input(&changeset, "birthday", "Birthday"))
    (datetime_input(&changeset, "starts_at", "Starts at"))
}

<input type="datetime-local"> has no timezone concept, so a chrono::DateTime<Utc> field's rendered value never carries an offset — chrono's default Deserialize for DateTime<Utc> requires one and rejects the submission. Attach deserialize_datetime_local_utc (or deserialize_datetime_local_utc_option for Option<DateTime<Utc>>):

Rust
#[derive(serde::Deserialize)]
struct EventForm {
    #[serde(deserialize_with = "autumn_web::form::deserialize_datetime_local_utc")]
    starts_at: chrono::DateTime<chrono::Utc>,
}

chrono::NaiveDateTime fields don't hit that offset problem, but a value the user actively edits through the browser's native picker isn't guaranteed to include seconds (unlike the always-seconds-inclusive pre-filled value), which chrono's default Deserialize also rejects. Attach deserialize_naive_datetime_local (or deserialize_naive_datetime_local_option for Option<NaiveDateTime>) as a defensive measure:

Rust
#[derive(serde::Deserialize)]
struct EventForm {
    #[serde(deserialize_with = "autumn_web::form::deserialize_naive_datetime_local")]
    starts_at: chrono::NaiveDateTime,
}

select_input

Renders a labelled <select> from (value, label) option pairs, marking the option matching the changeset's current value as selected. This is the control closed-set fields (enums, references) target.

Rust
let statuses = [("draft", "Draft"), ("published", "Published")];
html! {
    (select_input(&changeset, "status", "Status", &statuses))
}

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.


Typed accessible primitives (autumn_web::a11y)

The form helpers above make the right thing easy. The primitives in autumn_web::a11y go one step further and make the wrong thing impossible: they encode the accessible name as a type-level obligation, so an image without alt text, a button without a name, or an input without a label does not compile. This is accessibility conformance by construction, proven at build time by the framework's compile-fail test harness.

Each primitive implements maud::Render, so it splices straight into an html! block, and maps to a specific WCAG 2.1 success criterion:

PrimitiveWCAG SCObligation
Img1.1.1 Non-text Contentalt is a required constructor argument
Button4.1.2 Name, Role, Valueaccessible name is a required constructor argument
Link2.4.4 Link Purpose (In Context) / 4.1.2 Name, Role, Valuelink text is a required constructor argument
MenuItem4.1.2 Name, Role, Valueaccessible name is a required constructor argument (renders role="menuitem")
TextField1.3.1 Info and Relationships / 3.3.2 Labels / 4.1.2only a labeled field can be rendered (typestate)
Rust
use autumn_web::a11y::{Button, Img, Link, MenuItem, TextField};
use autumn_web::html;

let page = html! {
    (Img::new("/logo.svg", "Autumn logo"))                 // alt required
    (Img::decorative("/divider.svg"))                       // explicit alt=""
    (TextField::new("email").input_type("email").label("Email address"))
    (Button::icon(html! { span aria-hidden="true" { "🗑" } }, "Delete"))
    (Button::new("Save").submit())
    (Link::new("/about", "About us"))                       // link text required
    (Link::new("https://example.com", "Docs").new_tab())    // rel="noopener noreferrer"
    (MenuItem::new("Settings"))                             // role="menuitem", name required
    (MenuItem::new("Home").href("/"))                       // link-style menu item
};

Link::new takes the visible link text as a required argument (an icon-only link routes its name to aria-label via Link::icon), and MenuItem::new requires the accessible name, emitting role="menuitem" on a <button> by default or an <a> when an .href(..) is attached.

Red build → fix → green build

Red — an alt-less image or an unlabeled input used to compile clean. With the typed primitives it does not:

Rust
// error[E0061]: this function takes 2 arguments but 1 argument was supplied
let _ = Img::new("/logo.png");

// error[E0061]: this function takes 2 arguments but 1 argument was supplied
//        — a link with no text has no accessible name
let _ = Link::new("/about");

// error[E0061]: this function takes 1 argument but 0 arguments were supplied
//        — a menu item must carry an accessible name
let _ = MenuItem::new();

// error: no method named `render` found for `TextField<NoLabel>`
//        — an unlabeled field cannot be turned into markup
let _ = TextField::new("email").render();

Fix — supply the accessible name / attach a label:

Rust
let _ = Img::new("/logo.png", "Company logo");            // ✅ compiles
let _ = Link::new("/about", "About us");                  // ✅ compiles
let _ = MenuItem::new("Settings");                        // ✅ compiles
let _ = TextField::new("email").label("Email").render();  // ✅ compiles

Green — the build passes only once every primitive carries its accessible name. TextField::new(..) returns a TextField<NoLabel>; attaching a label with .label(..), .aria_label(..), or .labelled_by(..) transitions it to TextField<Labeled>, the only state that implements Render. There is no .render()/.build() on the unlabeled state, so an unlabeled field is literally unrepresentable as markup.

What this proves at compile time: the presence of an accessible name on every image, button, and text field. What still belongs to runtime/authoring review: whether that name is meaningful (a helpful alt, not "image"), plus page structure, contrast, and focus order — the checklist and audit tooling above cover those.


autumn a11y verify (build-time raw-html! audit)

The typed primitives above are the compile-time proof: code that uses Img, Button, Link, MenuItem, or TextField cannot ship without an accessible name, so it never needs re-checking. But a project can always drop down to raw maud::html! { … } markup, which bypasses the primitives entirely and the type system cannot see. autumn a11y verify is the net for that escape hatch.

Because there is no walkable widget tree at runtime, verify is a static pass: it token-scans the html! blocks in your project's .rs files (the same descent autumn i18n check uses to find t! calls inside html!) and reports raw elements that are missing an accessible name. It reuses the WCAG success criteria, rule ids, and severity levels of autumn check --a11y, applied to source rather than rendered HTML.

What it checks

Rule idElementConditionWCAG SC
image-alt<img>no alt attribute1.1.1
label<input> / <select> / <textarea>no matching <label for=…>, aria-label, or aria-labelledby1.3.1 / 3.3.2 / 4.1.2
button-name<button>no text content and no aria-label/aria-labelledby4.1.2
link-name<a href>no link text and no aria-label/aria-labelledby2.4.4 / 4.1.2

Each finding carries the fix hint — the typed primitive that discharges the obligation at compile time (Img::new(src, alt), TextField::new(..).label(..), Button::new(name), Link::new(href, text)).

Usage

Shell
# Audit the current project (defaults to the working directory)
autumn a11y verify

# Point at a specific crate/directory
autumn a11y verify ./crates/web

# Machine-readable conformance manifest
autumn a11y verify --format json

# Fail on any finding (Moderate and above), mirroring `i18n check --strict`
autumn a11y verify --strict

--format json (conformance manifest)

The JSON output is an array of findings keyed to WCAG success criteria plus a summary, suitable for archiving as a conformance manifest:

Json
{
  "files_scanned": 12,
  "html_blocks": 34,
  "findings": [
    {
      "file": "src/views/profile.rs",
      "line": 42,
      "element": "img",
      "rule_id": "image-alt",
      "wcag": "1.1.1",
      "severity": "Serious",
      "message": "raw <img> has no alt attribute",
      "hint": "use autumn_web::a11y::Img::new(src, alt) / Img::decorative(src)"
    }
  ],
  "summary": { "critical": 0, "serious": 1, "moderate": 0, "total": 1 }
}

CI integration

autumn a11y verify exits non-zero when any finding meets the failure threshold (Serious by default; --strict lowers it to Moderate), so it fails the build just like autumn i18n check:

Yaml
# GitHub Actions example
- name: Accessibility (raw html!) verification
  run: autumn a11y verify --format json

Scope and limitations

The primitives are the proof; verify is the safety net. Code that splices a typed primitive — (Img::new(src, alt)) — is a (expr) splice, not an img element, so it is never re-scanned or falsely flagged. Like autumn i18n check, the scanner reads tokens rather than a resolved AST and always errs toward not flagging what it cannot resolve, so it never breaks CI on a false positive: a spliced attribute value or content (alt=(caption), button { (label) }) is treated as present, and a <label for=…>/id association a splice makes unresolvable suppresses the label finding.

In short, verify is best-effort and advisory. Because it prefers to skip anything ambiguous, dynamic, or non-maud — unresolved splices, complex Rust expressions, and non-maud html! macros are passed over rather than guessed at — a clean run is not itself a proof of accessibility: the scanner can miss a defect buried in exotic markup (an acceptable trade by design), so its raw-html! findings are advisory-grade signal and may not be exhaustive. For the real, by-construction guarantee, route accessible content through the typed primitives in autumn_web::a11y, where an accessible name is a compile-time type obligation (proven by trybuild) rather than something a source scan has to infer.


Further reading