Work sample · Accessibility remediation
WCAG 2.2 remediation: a booking form, before and after
This is a demo we built for this sample, not client work. Tidewell Bikes is a fictional business. We wrote the “before” page with defects that are common on real booking and contact forms, fixed them on the “after” page, and checked each fix. Neither form sends anything.
Summary
- 16issues on the before page
- 6high severity
- 8medium severity
- 2low severity
All 16 are fixed on the after page. The automated check went from 23 failures to 0, and the keyboard walk-through that failed on the before page (the booking button could not be reached, and focus was invisible) passed on the after page.
Severity is our own scale: high stops some people from completing the booking, medium makes it harder or fails a Level AA criterion, low is a smaller or advisory issue.
Findings
| # | Issue on the before page | WCAG 2.2 | Severity | What we changed | How we checked |
|---|---|---|---|---|---|
| 1 | “Full name”, “Service” and “Tell us about your bike” are plain text next to the fields, not labels. The browser gave the name field and the notes field no name at all and named the service menu after its first option, “Choose...”. | 1.3.1 (A)Info and Relationships4.1.2 (A)Name, Role, Value | High | A <label for> for every field. Optional fields say “(optional)”. |
Automated check; accessibility tree now shows “Full name”, “Email address”, “Service”, “Preferred date”. |
| 2 | Email and preferred date use a placeholder as their only label. It disappears once you type, and nothing says which date format is expected. | 3.3.2 (A)Labels or Instructions | High | Visible labels, plus hints that stay on screen: “We send the booking confirmation here.” and “Day, month and year, for example 14/05/2027.”, tied to the field with aria-describedby. |
Automated check; accessibility tree. |
| 3 | The workshop drawing has no alt attribute. |
1.1.1 (A)Non-text Content | Medium | alt="Drawing of a bicycle on a repair stand in the Tidewell workshop" |
Automated check; accessibility tree. |
| 4 | The logo is the only content of the home link and has no alt, so the link has no name (W3C failure F89). |
2.4.4 (A)Link Purpose (In Context)4.1.2 (A)Name, Role, Value | High | alt="Tidewell Bikes home" on the logo image. |
Automated check; accessibility tree. |
| 5 | “For our opening hours and service prices, click here or here.” Two links, two destinations, and nothing in the sentence says which is which. | 2.4.4 (A)Link Purpose (In Context) | Medium | Link text says where it goes: “opening hours”, “services we offer”, “Service week details”. | Automated check; accessibility tree. |
| 6 | “Book now” is a div with a click handler. It has no button role and never receives keyboard focus, so the form cannot be sent without a mouse (W3C failures F54 and F59). |
2.1.1 (A)Keyboard4.1.2 (A)Name, Role, Value | High | A native <button type="submit">. Validation runs on the form’s submit event, so Enter in a field also sends it. |
Keyboard: before, Tab passed 10 stops and skipped the button; after, the button is reached and Enter submits. Accessibility tree: button “Book now”. |
| 7 | .shop *:focus { outline: none; } removes the focus indicator from every link and field in the demo (W3C failure F78). |
2.4.7 (AA)Focus Visible | High | :focus-visible { outline: 3px solid #1a56db; outline-offset: 2px; } |
Keyboard: computed outline was none on all 10 stops before and a 3 px solid ring on every stop after; screenshots. Ring contrast in the table below. |
| 8 | After a failed submit the only sign of an error is a red border on the field. No text says what is wrong. | 3.3.1 (A)Error Identification1.4.1 (A)Use of Color | High | A text message above each field in error, with a hidden “Error:” prefix for screen readers, aria-invalid="true" and aria-describedby. The thicker border and left bar stay as a second cue. An error summary lists every problem with a link to the field. |
Browser: an empty submit gave 4 red borders and no text before; 4 messages, 4 aria-invalid fields and a summary after. |
| 9 | When the email or date is in the wrong format, nothing tells the user how to correct it. | 3.3.3 (AA)Error Suggestion | Medium | Messages that say how to fix it: “Enter an email address in the correct format, like [email protected]”, “Enter the date as day, month and year, for example 14/05/2027”. | Browser: typed sam@ and 31/02/2027; both messages appeared. |
| 10 | The result of pressing “Book now” (errors or the thank-you text) appears silently. Screen reader users are not told anything happened. | 4.1.3 (AA)Status Messages | Medium | On errors, focus moves to the error summary, so it is read at once. The thank-you message is written into a role="status" region, which is announced without moving focus (W3C technique ARIA22). |
Browser: focus landed on “There are 4 problems”; the confirmation appeared in the status region shown in the accessibility tree. Not yet heard on a screen reader (see limits). |
| 11 | Text below 4.5:1: help text 2.52:1, links 2.64:1 (2.43:1 on the offer panel), button text 2.02:1, placeholders 1.78:1. | 1.4.3 (AA)Contrast (Minimum) | Medium | New colours, every pair 7:1 or higher. No placeholders. | Computed with the WCAG formula (table below). |
| 12 | Field borders are #e3e3e3 on white, 1.28:1, so the fields are hard to find. |
1.4.11 (AA)Non-text Contrast | Medium | #767676 borders, 4.54:1 on white. |
Computed with the WCAG formula. |
| 13 | The offer’s close button is 16 × 16 CSS px and sits 1 px from the “See details” link. A 24 px circle centred on it overlaps the link by 3 px, so the spacing exception does not apply. Its only name is “×”, and closing the offer deletes the focused button without moving focus anywhere. | 2.5.8 (AA)Target Size (Minimum) | Medium | A 32 × 32 px button at the end of the panel, named “Dismiss the service week offer”. After closing, focus moves to the page heading. | Measured in the browser with getBoundingClientRect: 16 × 16 with a 1 px gap before; 32 × 32 after. Keyboard: Enter on the button hid the offer and focus moved to “Tidewell Bikes”. |
| 14 | The page has no lang attribute, so screen readers guess the language. |
3.1.1 (A)Language of Page | Medium | <html lang="en"> |
Automated check. |
| 15 | Name and email fields have no autocomplete values, so browsers cannot fill them in reliably. |
1.3.5 (AA)Identify Input Purpose | Low | autocomplete="name" and autocomplete="email". |
Automated check. |
| 16 | Headings jump from h1 to h4. This is not a failure by itself; W3C technique G141 advises nesting headings in order so the outline makes sense. |
AdvisoryG141, related to 1.3.1 | Low | h2 for the page sections, h3 inside the error summary. |
Automated check; accessibility tree. |
Contrast, with the numbers
| Element | Before | After | Needs |
|---|---|---|---|
| Help and hint text | #a3a3a3 on #ffffff: 2.52:1 | #595959 on #ffffff: 7.00:1 | 4.5:1 |
| Links | #58a6e0 on #ffffff: 2.64:1#58a6e0 on #fff4e0: 2.43:1 | #1d5c4b on #ffffff: 7.82:1 | 4.5:1 |
| Button text | #ffffff on #f6a623: 2.02:1 | #ffffff on #1d5c4b: 7.82:1 | 4.5:1 |
| Placeholder text | #c2c2c2 on #ffffff: 1.78:1 | No placeholders; labels #1f2328: 15.80:1 | 4.5:1 |
| Error text | None (red border only) | #b3261e on #ffffff: 6.54:1 | 4.5:1 |
| Field border | #e3e3e3 on #ffffff: 1.28:1 | #767676 on #ffffff: 4.54:1 | 3:1 |
| Focus indicator | None | #1a56db on #ffffff: 6.18:1#1a56db on #fff4e0: 5.67:1 | 3:1 |
Worked example: help text #a3a3a3 on white
Each channel is 163, so the sRGB value is 163 / 255 = 0.6392. It is above 0.04045, so it is linearised as ((0.6392 + 0.055) / 1.055)2.4 = 0.3663. For a grey all three channels match, so the relative luminance is L = 0.2126 × 0.3663 + 0.7152 × 0.3663 + 0.0722 × 0.3663 = 0.3663. White has L = 1. The contrast ratio is (1 + 0.05) / (0.3663 + 0.05) = 2.52:1, below the 4.5:1 minimum. The replacement #595959 gives L = 0.0999 and (1.05) / (0.1499) = 7.00:1.
Keyboard walk-through
- Before: Tab moved through 10 stops in the demo (logo link, “See details”, the close button, “click here”, “here”, then the five fields) and then went back to the top of the page. “Book now” was never reached. No stop showed a focus indicator.
- After: a skip link comes first, then the same content in reading order, ending at “Book now”. Every stop showed the blue ring. Enter on “Book now” with an empty form moved focus to the error summary (“There are 4 problems”); Enter on a summary link moved focus to that field. With valid values, the confirmation appeared in the status region. Enter on the close button hid the offer and moved focus to the page heading.
- Narrow screens: at 320 CSS px wide the after page did not scroll sideways.
Automated check
A small static check we wrote for this sample in Node.js, using only built-in modules. It reads each page and its stylesheet and reports what a static check can see: language, title, text alternatives, field labels, autocomplete, link text, heading order, non-native buttons, live regions, contrast from the CSS, focus styles and target sizes. Read the script.
$ node check.mjs before.html after.html
== before.html: 23 fail, 7 warn, 10 pass, 0 info
== after.html: 0 fail, 0 warn, 37 pass, 0 info
Full output for both pages
$ node check.mjs before.html after.html
== before.html: 23 fail, 7 warn, 10 pass, 0 info
FAIL 3.1.1 <html> has no lang attribute
PASS 2.4.2 <title> is "Book a service - Tidewell Bikes (intentionally inaccessible demo)"
FAIL 1.1.1 <img src="demo-logo.svg"> has no alt attribute
FAIL 1.1.1 <img src="demo-workshop.svg"> has no alt attribute
FAIL 1.3.1/4.1.2 <input#name> has no programmatic label
FAIL 1.3.5 <input#name> collects "name" without an autocomplete value
FAIL 3.3.2 <input#email> has only a placeholder ("Email *") and no label
FAIL 1.3.5 <input#email> collects "email" without an autocomplete value
FAIL 1.3.1/4.1.2 <select#service> has no programmatic label
FAIL 3.3.2 <input#date> has only a placeholder ("Preferred date *") and no label
FAIL 1.3.1/4.1.2 <textarea#notes> has no programmatic label
FAIL 2.4.4/4.1.2 link to /work/wcag-remediation/before has no accessible name
FAIL 2.4.4 "click here" (to #hours) shares its paragraph with another vague link, so context cannot tell them apart
FAIL 2.4.4 "here" (to #prices) shares its paragraph with another vague link, so context cannot tell them apart
WARN 4.1.2 <button.close.js-close> is named only "×"
WARN 1.3.1 (advisory, G141) <h4> "Book a service" follows <h1>, skipping a level
FAIL 2.1.1/4.1.2 <div.btn.js-book> "Book now" looks like a button but is not one: no button role and not in the tab order
WARN 4.1.3 form has no live region (role=status/alert or aria-live); check how results are announced
WARN 3.3.1 <input#name> has no error text tied to it by aria-describedby
WARN 3.3.1 <input#email> has no error text tied to it by aria-describedby
WARN 3.3.1 <select#service> has no error text tied to it by aria-describedby
WARN 3.3.1 <input#date> has no error text tied to it by aria-describedby
PASS 1.4.3 body: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
PASS 1.4.3 .demo-banner: text #ffffff on #1b1a17 = 17.40:1 (needs 4.5:1)
PASS 1.4.3 .demo-banner a: text #ffffff on #1b1a17 = 17.40:1 (needs 4.5:1)
PASS 1.4.3 .promo: text #333333 on #fff4e0 = 11.59:1 (needs 4.5:1)
FAIL 1.4.3 .promo a: text #58a6e0 on #fff4e0 = 2.43:1 (needs 4.5:1)
PASS 1.4.3 .close: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
FAIL 1.4.3 a: text #58a6e0 on #ffffff = 2.64:1 (needs 4.5:1)
PASS 1.4.3 input: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
PASS 1.4.3 select: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
PASS 1.4.3 textarea: text #333333 on #ffffff = 12.63:1 (needs 4.5:1)
FAIL 1.4.3 input::placeholder: text #c2c2c2 on #ffffff = 1.78:1 (needs 4.5:1)
FAIL 1.4.3 textarea::placeholder: text #c2c2c2 on #ffffff = 1.78:1 (needs 4.5:1)
FAIL 1.4.3 .help: text #a3a3a3 on #ffffff = 2.52:1 (needs 4.5:1)
FAIL 1.4.3 .btn: text #ffffff on #f6a623 = 2.02:1 (needs 4.5:1)
PASS 1.4.3 .msg.ok: text #2e7d32 on #ffffff = 5.13:1 (needs 4.5:1)
FAIL 1.4.11 form field border #e3e3e3 on #ffffff = 1.28:1 (needs 3:1)
FAIL 2.4.7 .shop *:focus sets outline: none and no general :focus-visible rule replaces it
FAIL 2.5.8 .close is 16 x 16 CSS px (under 24 x 24; spacing exception checked in the browser)
== after.html: 0 fail, 0 warn, 37 pass, 0 info
PASS 3.1.1 <html lang="en">
PASS 2.4.2 <title> is "Book a service - Tidewell Bikes (fixed demo)"
PASS 1.1.1 <img src="demo-logo.svg"> alt="Tidewell Bikes home"
PASS 1.1.1 <img src="demo-workshop.svg"> alt="Drawing of a bicycle on a repair stand in the Tidewell workshop"
PASS 1.3.1/4.1.2 <input#name> is labelled by label[for]: "Full name"
PASS 1.3.5 <input#name> autocomplete="name"
PASS 1.3.1/4.1.2 <input#email> is labelled by label[for]: "Email address"
PASS 1.3.5 <input#email> autocomplete="email"
PASS 1.3.1/4.1.2 <select#service> is labelled by label[for]: "Service"
PASS 1.3.1/4.1.2 <input#date> is labelled by label[for]: "Preferred date"
PASS 1.3.1/4.1.2 <textarea#notes> is labelled by label[for]: "Tell us about your bike (optional)"
PASS 4.1.2 <button.close.js-close> is named "Dismiss the service week offer"
PASS 4.1.2 <button.btn> is named "Book now"
PASS 4.1.3 live region present: <div#status.status>
PASS 3.3.1 <input#name> is described by an error message container <p#name-error.error>
PASS 3.3.1 <input#email> is described by an error message container <p#email-error.error>
PASS 3.3.1 <select#service> is described by an error message container <p#service-error.error>
PASS 3.3.1 <input#date> is described by an error message container <p#date-error.error>
PASS 1.4.3 body: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS 1.4.3 .skip-link: text #1d5c4b on #ffffff = 7.82:1 (needs 4.5:1)
PASS 1.4.3 .demo-banner: text #ffffff on #1b1a17 = 17.40:1 (needs 4.5:1)
PASS 1.4.3 .demo-banner a: text #ffffff on #1b1a17 = 17.40:1 (needs 4.5:1)
PASS 1.4.3 a: text #1d5c4b on #ffffff = 7.82:1 (needs 4.5:1)
PASS 1.4.3 .promo: text #1f2328 on #fff4e0 = 14.50:1 (needs 4.5:1)
PASS 1.4.3 .close: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS 1.4.3 .hint: text #595959 on #ffffff = 7.00:1 (needs 4.5:1)
PASS 1.4.3 .help: text #595959 on #ffffff = 7.00:1 (needs 4.5:1)
PASS 1.4.3 input: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS 1.4.3 select: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS 1.4.3 textarea: text #1f2328 on #ffffff = 15.80:1 (needs 4.5:1)
PASS 1.4.3 .error: text #b3261e on #ffffff = 6.54:1 (needs 4.5:1)
PASS 1.4.3 .error-summary a: text #b3261e on #ffffff = 6.54:1 (needs 4.5:1)
PASS 1.4.3 .btn: text #ffffff on #1d5c4b = 7.82:1 (needs 4.5:1)
PASS 1.4.3 .status.ok: text #17683c on #ffffff = 6.81:1 (needs 4.5:1)
PASS 1.4.11 form field border #767676 on #ffffff = 4.54:1 (needs 3:1)
PASS 2.4.7 :focus-visible { outline: 3px solid #1a56db }; ring #1a56db on #ffffff = 6.18:1
PASS 2.5.8 .close is 32 x 32 CSS px
Limits of this sample
- Both pages are small and static, and we wrote them. Real sites add menus, dialogs, carousels, third-party widgets and many more states.
- Everything was checked on one Mac, in Chromium 152. We did not test with VoiceOver, NVDA, JAWS or TalkBack, on Windows, in Safari or Firefox, or on a phone.
- The accessibility tree shows names, roles and states. It does not show what a screen reader says when content changes, so the announcements in finding 10 follow W3C techniques but were not listened to.
- The automated script is a narrow static check, not a full rule engine. It cannot see content created by script or keyboard behaviour; those were checked by hand in the browser.
- WCAG conformance applies to complete pages and processes on a real site. This sample makes no conformance claim for any site.
Sources
Success criterion names, numbers and levels were checked against the W3C pages below on 29 September 2026.
- WCAG 2.2 (W3C Recommendation, 12 December 2024): https://www.w3.org/TR/WCAG22/
- Relative luminance and contrast ratio definitions: https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
- Understanding WCAG 2.2 (one page per criterion, linked in the findings table): https://www.w3.org/WAI/WCAG22/Understanding/
- Failures F54, F59, F65, F68, F78, F89 and techniques ARIA22, G141: https://www.w3.org/WAI/WCAG22/Techniques/
- W3C forms tutorial on placeholder text: https://www.w3.org/WAI/tutorials/forms/instructions/