Let's skip the "it's the right thing to do" argument for a moment. Yes, it is. But if you need to convince your product manager, here are the numbers that actually move budgets:
An inaccessible checkout doesn't just exclude users — it's a legal liability. Under the ADA, the European Accessibility Act (taking effect June 2025), and PSD2 requirements, payment interfaces in many jurisdictions must be accessible. I've seen a mid-size payment processor get hit with a demand letter specifically citing their checkout flow's lack of keyboard navigation. The remediation cost them six figures and three months of engineering time. Fixing it during development would have taken a week.
The business case is simple: if 26% of your potential customers can't complete checkout, you're leaving revenue on the table. One FinTech I worked with saw a 12% increase in successful transactions after an accessibility remediation sprint — mostly from fixing keyboard navigation and error announcement issues.
Card input fields are where most payment forms fall apart. The pattern seems straightforward — card number, expiry, CVV — but the implementation details matter enormously for assistive technology users.
Label Association
Every input needs a programmatically associated label. Not a placeholder. Not a floating label that disappears on focus. A real <label> element with a for attribute pointing to the input's id.
Don't Do This
<input
type="text"
placeholder="Card number"
class="card-input"
/>
<!-- Screen reader: "edit text" -->
<!-- User has no idea what
this field is for -->
Do This Instead
<label for="card-num">
Card number
</label>
<input
id="card-num"
type="text"
inputmode="numeric"
autocomplete="cc-number"
aria-describedby="card-err"
/>
Error Announcements
When a card number is invalid, sighted users see a red message. Screen reader users hear... nothing, unless you've wired up the error correctly. The error container needs aria-describedby on the input and role="alert" or aria-live="assertive" on the error element so it's announced when it appears.
<label for="card-num">Card number</label>
<input
id="card-num"
type="text"
inputmode="numeric"
autocomplete="cc-number"
aria-invalid="true"
aria-describedby="card-num-error"
/>
<div id="card-num-error" role="alert">
Please enter a valid 16-digit card number
</div>
Watch out: Don't use aria-live="assertive" on every error message simultaneously. If a user submits a form with three invalid fields, three assertive announcements will queue up and talk over each other. Use role="alert" on individual field errors, or announce a single summary like "3 errors found" at the top of the form.
Focus Management
After a failed submission, move focus to the first invalid field. This is the single most impactful thing you can do for keyboard and screen reader users. Without it, they're stranded at the submit button with no idea what went wrong.
function handleSubmitError(errors) {
const firstError = document.getElementById(errors[0].fieldId);
if (firstError) {
firstError.focus();
// Screen reader will announce the field label
// + the error via aria-describedby
}
}
Payment dashboards and checkout flows have dynamic content that changes without a page reload — transaction status, balance updates, payment confirmations. If you're not using aria-live regions, screen reader users are completely in the dark about these changes.
Transaction Status Updates
When a payment goes from "Processing" to "Approved" or "Declined," that status change needs to be announced. Use aria-live="polite" for non-urgent updates and aria-live="assertive" for critical ones like declines.
<!-- Balance display — polite, updates in background -->
<div aria-live="polite" aria-atomic="true">
Available balance: <span id="balance">$1,240.50</span>
</div>
<!-- Transaction result — assertive, user needs to know -->
<div aria-live="assertive" role="status">
<span class="sr-only" id="tx-status"></span>
</div>
<script>
// When transaction completes:
document.getElementById('tx-status').textContent =
'Payment of $49.99 approved. Confirmation number: TXN-8842.';
</script>
Key detail: Add aria-atomic="true" to balance displays. Without it, a screen reader might only announce the changed portion ("$1,240.50") without context. With aria-atomic="true", it reads the entire region: "Available balance: $1,240.50."
Color Contrast in Payment Status Indicators
Every payment dashboard I've reviewed uses green for approved, red for declined, and yellow or orange for pending. That's fine — but color alone is never enough. Roughly 8% of men have some form of color vision deficiency. If your "Approved" and "Declined" badges differ only by color, they're indistinguishable for a significant chunk of your users.
Color Only
Relies solely on color — fails WCAG 1.4.1
Color + Icon + Text
Icon shape + text label — passes WCAG 1.4.1
Also check your contrast ratios. That light green text on a white badge? It probably doesn't hit the 4.5:1 ratio required by WCAG AA. I use #15803d instead of #16a34a for green status text on light backgrounds — it's still clearly green but actually passes contrast checks.
Keyboard Navigation for Multi-Step Checkout
Multi-step checkout flows are a keyboard navigation minefield. Users need to move between steps, understand where they are in the process, and not lose their place when validation fails. Here's the flow pattern I use:
The critical rules for keyboard-accessible multi-step flows:
- Move focus to the step heading or first input when transitioning between steps. Don't leave focus on the "Next" button from the previous step — the user will be tabbing through invisible content.
- Use
aria-current="step"on the active step indicator in your progress bar. Screen readers will announce "Step 2 of 4, current" instead of just "Step 2 of 4." - Allow backward navigation. If a user presses Shift+Tab from the first field in Step 3, they should reach the "Back" button, not get trapped. I've seen checkout flows where the back button wasn't even in the tab order.
- Announce step transitions with an
aria-liveregion: "Step 2 of 4: Shipping address."
<!-- Progress indicator with aria-current -->
<ol class="checkout-steps" aria-label="Checkout progress">
<li aria-current="false">Cart</li>
<li aria-current="step">Shipping</li>
<li aria-current="false">Payment</li>
<li aria-current="false">Confirm</li>
</ol>
<!-- Step transition announcement -->
<div aria-live="polite" class="sr-only">
Step 2 of 4: Shipping address
</div>
I've reviewed dozens of payment form implementations. These are the mistakes I see over and over again, even from senior engineers who should know better.
Custom Dropdowns That Aren't Dropdowns
Engineers love building custom select menus for expiry month/year pickers. The styled <div> looks great, but it's completely invisible to screen readers unless you rebuild the entire ARIA listbox pattern — role="listbox", role="option", aria-expanded, aria-activedescendant, keyboard arrow navigation, Home/End key support. That's a lot of work to replicate what <select> gives you for free.
Hot take: Just use a native <select> for expiry date fields. Style it with appearance: none and a custom arrow if you need to. The 2 hours you spend fighting a custom dropdown's accessibility is 2 hours you could spend on something that actually matters. I've never seen a user abandon checkout because the expiry dropdown looked too "native."
Masked Inputs That Break Everything
Card number masking — adding spaces every 4 digits as the user types — is a UX pattern that actively fights assistive technology. Input masks manipulate the value on every keystroke, which causes screen readers to re-announce the field, lose cursor position, and sometimes read the entire masked value character by character.
If you must mask, use the beforeinput event instead of input, and update the visual formatting without changing the underlying value. Or better yet, use inputmode="numeric" and autocomplete="cc-number" and let the browser handle formatting. Modern browsers and password managers already know how to format card numbers.
Auto-Advancing Fields
Some payment forms automatically move focus to the next field when the current one is "full" — type 4 digits in the card number segment, focus jumps to the next segment. This is disorienting for screen reader users who don't expect focus to move without their action. It also breaks users who need to go back and correct a digit.
Don't auto-advance. Use a single input for the full card number. If you're using a hosted field iframe (Stripe Elements, Adyen Drop-in), the provider handles this — but test it with a screen reader anyway, because not all of them get it right.
Missing autocomplete Attributes
This one is almost free and almost always forgotten. The autocomplete attribute tells browsers and password managers exactly what each field expects:
<input autocomplete="cc-name" /> <!-- Cardholder name -->
<input autocomplete="cc-number" /> <!-- Card number -->
<input autocomplete="cc-exp" /> <!-- Expiry (MM/YY) -->
<input autocomplete="cc-csc" /> <!-- CVV/CVC -->
Without these, autofill doesn't work, password managers can't populate the form, and users with motor disabilities who rely on autofill to avoid typing are stuck entering 16 digits manually. WCAG 1.3.5 specifically requires this for user input fields.
Testing Approach — What Actually Catches Bugs
Automated tools catch about 30-40% of accessibility issues. The rest require manual testing. Here's the testing stack I use on every payment interface project:
Automated: axe-core in CI
Run axe-core in your integration tests. It catches missing labels, contrast failures, duplicate IDs, and ARIA misuse. Integrate it into Cypress or Playwright so every PR gets checked:
// Playwright + axe-core example
import AxeBuilder from '@axe-core/playwright';
test('checkout form has no a11y violations', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page })
.include('.checkout-form')
.analyze();
expect(results.violations).toEqual([]);
});
Manual: Screen Reader Testing
Test with at least two screen readers. I use VoiceOver on macOS (free, built-in) and NVDA on Windows (free, open source). The experience differs significantly between them — NVDA with Firefox handles aria-live regions differently than VoiceOver with Safari. Walk through the entire checkout flow: fill in the card number, trigger a validation error, submit, and verify the confirmation is announced.
Manual: Keyboard-Only Testing
Unplug your mouse and try to complete a purchase using only the keyboard. You'll find issues in the first 30 seconds. Can you reach every field with Tab? Can you activate the submit button with Enter? Can you navigate back to a previous step? Can you dismiss a modal with Escape? If any of these fail, your keyboard users are stuck.
My testing rule of thumb: axe-core in CI catches the obvious stuff. One full screen reader walkthrough per sprint catches the interaction bugs. Keyboard-only testing before every release catches the focus management issues. All three together give you about 85% coverage — the remaining 15% comes from real users with disabilities, which is why user testing matters too.
References
- W3C WAI — ARIA Authoring Practices Guide (APG)
- W3C — WCAG 2.1 Quick Reference
- WebAIM — Creating Accessible Forms
- WebAIM — The WebAIM Million (Annual Accessibility Report)
- MDN — ARIA Live Regions
- MDN — Web Accessibility Guide
- W3C WAI — Forms Tutorial
- axe-core — Accessibility Testing Engine
Disclaimer: This article reflects the author's personal experience and opinions. Accessibility compliance requires testing with real assistive technologies and expert review — code patterns alone do not guarantee WCAG conformance. Statistics cited are from publicly available sources and may have been updated since publication. Product names and brands are property of their respective owners.