MangoesToMetro

Building an Accessible WordPress Order Form

May 7, 2026 · 8 min read

Custom branded MangoesToMetro administrator login page with username and password fields

The MangoesToMetro order form is the only part of the site that matters commercially. If it is unusable for someone, that is a lost order and a person who could not buy mangoes. This post covers how it was built to WCAG 2.1 AA, with the actual patterns rather than a checklist.

Order form showing delivery area pricing for Metro Manila and Metro Cebu, contact fields, quantity, QR payment and payment proof upload
The form under discussion. Every control on it is reachable and operable from a keyboard. Sample details shown.

The delivery area selector: a real radiogroup

The delivery area choice looks like two large tappable cards showing Metro Manila and Metro Cebu with their prices. The tempting implementation is two divs with click handlers. That produces something a mouse user can operate and nobody else.

It is built instead as a native radiogroup: real <input type="radio"> elements inside a <fieldset role="radiogroup"> with a <legend>, where the inputs are visually hidden with an .sr-only class and the visible cards are the <label> elements bound to them.

<fieldset role="radiogroup" aria-labelledby="dest-legend-label">
  <legend id="dest-legend-label" class="form-label">Shipping to</legend>
  <div class="dest-toggle">
    <label class="dest-option dest-option-label">
      <input type="radio" name="delivery_destination"
             id="dest-metro-manila" value="metro_manila" class="sr-only">
      <span class="dest-name">Metro Manila</span>
      <span class="dest-price">P1,600</span>
    </label>
    ...
  </div>
</fieldset>

Because the inputs are real radios, arrow key navigation between options, the grouped announcement from a screen reader, and form submission all work without a single line of JavaScript. .sr-only hides the input visually while leaving it in the accessibility tree and focusable, which is the entire difference between hiding and removing.

The one thing this pattern demands in return: because the visible focus ring would otherwise land on an invisible input, the focus style has to be drawn on the label instead. Skip that and you have built a keyboard-operable control that keyboard users cannot see themselves using.

Delivery area selector showing Metro Manila at 1,600 pesos and Metro Cebu at 1,500 pesos, above name, email and phone fields
Two large cards that are, underneath, an ordinary radiogroup.

The quantity stepper

The quantity control is a minus button, a value, and a plus button. Custom steppers are a classic place to strand assistive technology, so the value carries the spinbutton state explicitly:

role="group"
aria-valuenow="1"
aria-valuemin="1"
aria-valuemax="<?php echo (int) $max_qty; ?>"

The buttons carry aria-label values of “Increase quantity” and “Decrease quantity” rather than relying on a plus or minus glyph, which announces as nothing useful. The maximum comes from the plugin’s configured limit rather than being hard-coded, so the announced bound and the enforced bound cannot drift apart.

The file upload: the hardest control on the page

Buyers upload a screenshot of their payment. A styled drag and drop zone is the normal way to make that look decent, and it is also the normal way to build something only a mouse can use.

The approach is the same as the radiogroup. The real <input type="file"> stays in the DOM, visually hidden with .sr-only, and the drop zone is a <label> bound to it. A label bound to a file input is clickable and keyboard-activatable for free.

The instruction text says so out loud: “Click or press Enter/Space to upload, or drag and drop”. Naming the keyboard route in the visible copy is worth more than any amount of ARIA, because it tells everyone the control has one.

Payment screenshot upload area accepting JPG, PNG or PDF up to 5MB, with the order total and the place order button
The drop zone is a label for a hidden but focusable file input, and the copy states the keyboard route.

Error messages: alert versus polite

The form distinguishes two kinds of message, and uses a different live region for each. Field-level validation errors use role="alert", which interrupts and announces immediately, because the user has just done something that needs correcting before they can continue. There are seven of these, one per validated field, each tied to its input with aria-describedby so the error is read as part of the field rather than as a stray announcement.

Status messages that are informational use aria-live="polite" with aria-atomic="true", so they wait for a pause rather than cutting across whatever is being read.

Getting this backwards is one of the most common accessibility mistakes in forms. Polite validation errors get missed. Assertive status updates make a form feel like it is shouting.

Every required field is marked aria-required="true" as well as visually, so the requirement is not communicated by an asterisk alone.

Motion, gated the right way round

The site has scroll animations and transitions. They are gated on prefers-reduced-motion, and the direction of the test matters:

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

The animation is added only when the user has expressed no preference against it, rather than being applied by default and then stripped inside a reduce block. Written this way, anyone whose preference is unknown or whose browser does not report it gets the calm version. Motion is opt-in by absence of objection, not opt-out.

Focus visibility

Designs that remove outlines are everywhere, and every removal is a keyboard user losing their place on the page. The rule I followed is mechanical: every outline: none is paired with a :focus-visible style that puts a clearly visible ring back. There are fifteen :focus-visible rules in the main stylesheet, which is roughly the number of places a designer’s instinct wanted the outline gone.

:focus-visible rather than :focus is what makes this acceptable to the design side, because mouse clicks no longer leave a ring behind while keyboard navigation still shows one.

Labels that stay put

Every input has a persistent visible <label> with a matching for attribute. No placeholder-as-label anywhere. Placeholder text disappears the moment someone starts typing, which means the person most likely to lose the field’s meaning is the person who paused halfway through filling it in. It also fails contrast requirements in most designs, because placeholder grey is grey for a reason.

The admin login page counts

Accessibility work usually stops at the public site. The team signs in far more often than any single customer visits, so the custom login page got the same treatment: labelled fields, visible focus, and the same reduced-motion gating on its background animation.

What this cost

Honestly, not much. The radiogroup and the file input are the only two patterns that took real thought, and both came out simpler than the div-and-JavaScript versions would have been, because native elements do the work. The rest is discipline: label every input, pair every outline removal with a focus style, pick the right live region, and test the form with the keyboard before calling it done.

The thing I would tell anyone starting: build the accessible version first. Retrofitting a radiogroup onto two divs with click handlers is a rewrite. Starting from the native element and styling it is an afternoon.

Read next

The server side of this same form is in Engineering a secure WordPress ordering plugin.

Tags: , , , , ,

Got something to say?

Thoughts, reactions, questions, or just want to chat about it? I'd love to hear from you.

Drop Me a Message