Accessibility checks at compile time
By the end of this guide, missing alt text, unlabeled form fields, empty buttons and skipped heading levels stop your build with an error that says where the problem is and how to fix it, before a page reaches anyone.
Why check before the page exists
HTML accepts anything. An image without alt, an input without a label or a button with only an icon all render
fine, so the mistakes ship silently, and people using screen readers find them first.
Most teams test accessibility on rendered pages: a browser extension, Lighthouse, or axe in end-to-end tests. Those tools are good, and they find problems only a browser can see, like color contrast. But they check the pages and states your tests happen to visit, with the data your tests happen to use, after the code is written.
Without Hypra
The usual setup runs axe in Playwright tests:
$ npm install --save-dev @playwright/test @axe-core/playwright// tests/accessibility.spec.js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
for (const path of ["/", "/signup", "/shoes/7"]) {
test(`no accessibility violations on ${path}`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
}Keep this. It catches what only a browser can see. Its limits:
- It needs a running app with test data, and it checks only the URLs you list.
- A problem in a component shows up on every page that uses it, but only on pages you test.
- Failures arrive minutes later in CI, as a list of selectors, not as a line in your editor.
With Hypra
Hypra is a markup language that compiles to HTML. The compiler knows what every element is, so it checks accessibility while it compiles, on every page and every component, without running anything. Here's a sign-up page with five common mistakes:
page(title: "Sign up", description: "Create an account to save your favourite routes and get one weekly email with new trails.") {
h1 "Create an account"
img("/img/trail.jpg")
h3 "Why sign up?"
p { "Save routes and get new trails every week. " a("/about") "Click here" }
form("/signup", name: "signup") {
field("email", type: Email)
button
}
}$ hypra check signup.hypra
error[A11Y-001]: image has no alt text
--> signup.hypra:3:7
|
3 | img("/img/trail.jpg")
| ^^^^^^^^^^^^^^^^
= help: add alt: "..." or decorative: true
warning[A11Y-006]: h3 follows h1, skipping a level
--> signup.hypra:4:3
|
4 | h3 "Why sign up?"
| ^^
= help: use h2; screen readers navigate by heading levels
warning[A11Y-007]: link text `click here` doesn't say where it goes
--> signup.hypra:5:65
|
5 | p { "Save routes and get new trails every week. " a("/about") "Click here" }
| ^^^^^^^^^^^^
= help: describe the target, e.g. a("/guide") "Read the setup guide"
error[FORM-002]: `field` needs `label`
--> signup.hypra:7:5
|
7 | field("email", type: Email)
| ^^^^^
= help: add label: "..."
error[A11Y-005]: button has no text
--> signup.hypra:8:5
|
8 | button
| ^^^^^^
= help: button "Send", or aria-label: "..." for an icon button
3 errors, 2 warningsErrors stop the build; warnings don't. Every message has a code, the exact place and the fix. With the editor extension, they show up as you type.
The fixed page:
page(title: "Sign up", description: "Create an account to save your favourite routes and get one weekly email with new trails.") {
h1 "Create an account"
img("/img/trail.jpg", alt: "A runner on a muddy forest trail")
h2 "Why sign up?"
p { "Save routes and get new trails every week. " a("/about") "Read how we pick trails" }
form("/signup", name: "signup") {
field("email", type: Email, label: "Email")
button "Sign up"
}
}What it checks, among others:
| Check | Code |
|---|---|
images need alt text, or decorative: true; alt text like "image" or "photo" is a warning |
A11Y-001, A11Y-008 |
| links and buttons need text; link text says where it goes, not "click here", and means one place | A11Y-002, A11Y-005, A11Y-007, A11Y-013 |
| form fields need a label, and a form needs a button | FORM-002, FORM-003 |
headings go down one level at a time, and a page has one h1 |
A11Y-006, SEO-003 |
| videos need captions; autoplaying videos are muted | A11Y-003, A11Y-011 |
iframes need a title; no positive tabindex |
A11Y-009, A11Y-010 |
The error reference lists all of them. A few details that make this work on real sites:
Components are checked once, everywhere. A component without alt text on its image is an error where the component is written, not on each page that uses it.
Heading levels are checked as the page renders, so a component that starts with
h2is fine after anh1, and anh1inside a loop is caught.Text from data is checked when a page is built with that data, for example alt text from your database.
Exceptions are explicit. A rule can be turned off for one element, with a reason that shows up in review:
allow("A11Y-006") "the card title is styled small on purpose" h3 "Trail of the week"
Limits
- Hypra can't see what only a browser sees: color contrast, focus order in JavaScript widgets, or whether alt text is actually good. Keep running axe on rendered pages for those.
- It checks pages written in Hypra. Existing HTML or Blade templates aren't checked; you can move one page at a time.
- Hypra is new (version 0.1), and the language may still change before 1.0.
Next steps
- Install the compiler, or try it in Laravel with hypra/laravel.
- The language reference explains every element and check.