Accessibility pattern · Forms and flows

Payment form

One field for the card number, spaced in fours as it is typed without the caret jumping, with the brand named in words beside it; expiry and security code bring up the number keypad and take autofill. Every error says exactly what to fix, and the pay button shows its progress in words and a status message, so nobody presses it twice.

WCAG criteria
7
Keyboard rules
5
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Pay by card

Studio plan, billed yearly

Total ₹4,788

The long number on the front. We take Visa, Mastercard, RuPay and American Express.

Month and year, like 08/28.

3 digits on the back.

As it is printed on the card.

A demo: nothing is sent or charged. Try 4242 4242 4242 4242.

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
Tab or ShiftTabMoves through card number, expiry date, the What is this? button, security code, name on card and Pay.
BackspaceIn the card number or expiry date, just after a space or slash, deletes the digit before it rather than the separator.
Enter or SpaceOn What is this?, shows the note about the security code; pressed again, hides it.
EscapeHides the note; focus stays where it is.
EnterIn a field, pays: problems are listed in a summary that takes focus; otherwise the button shows its progress.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Focus reaches Card numberCard number, edit text, required. The long number on the front. We take Visa, Mastercard, RuPay and American Express.
The first digits of a Visa card are typedVisa
Focus reaches the button beside Security codeWhat is this security code?, button, collapsed
Enter shows the noteExpanded. The security code is the last 3 digits printed on the back of your card…
Pay is pressed with a date in the pastThere is a problem with your card details, group. This card has expired. Check the date or use another card
Pay is pressed with every field rightProcessing your payment. Please wait.
The payment goes throughPayment received. A receipt is on its way to your email.

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<form class="ap-payment-form" data-ap-payment-form novalidate aria-labelledby="pay-form-name">
  <div class="ap-payment-form__top">
    <div>
      <h3 class="ap-payment-form__name" id="pay-form-name">Pay by card</h3>
      <p class="ap-payment-form__sub">Studio plan, billed yearly</p>
    </div>
    <p class="ap-payment-form__amount"><span class="ap-payment-form__amount-k">Total</span> <strong translate="no">₹4,788</strong></p>
  </div>

  <div class="ap-payment-form__summary" id="pay-summary" role="group" aria-labelledby="pay-summary-name" aria-describedby="pay-summary-list" tabindex="-1" hidden>
    <h4 class="ap-payment-form__summary-name" id="pay-summary-name"><svg class="ap-payment-form__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg>There is a problem with your card details</h4>
    <ul class="ap-payment-form__summary-list" id="pay-summary-list"></ul>
  </div>

  <div class="ap-payment-form__body">
    <div class="ap-field ap-payment-form__field ap-payment-form__field--wide">
      <div class="ap-payment-form__label-row">
        <label class="ap-label" for="pay-number">Card number</label>
        <span class="ap-payment-form__brand" id="pay-brand" role="status"><svg class="ap-payment-form__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="5.5" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M7 15h3"/></svg><span data-ap-brand></span></span>
      </div>
      <p class="ap-hint" id="pay-number-hint">The long number on the front. We take Visa, Mastercard, RuPay and American Express.</p>
      <input class="ap-input ap-payment-form__mono" id="pay-number" name="cc-number" type="text" inputmode="numeric" autocomplete="cc-number" spellcheck="false" required aria-describedby="pay-number-hint pay-brand" />
      <p class="ap-error ap-payment-form__error" id="pay-number-error" hidden><svg class="ap-payment-form__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-payment-form__vh">Error:</span> <span data-ap-text></span></p>
    </div>

    <div class="ap-field ap-payment-form__field">
      <div class="ap-payment-form__label-row">
        <label class="ap-label" for="pay-exp">Expiry date</label>
      </div>
      <p class="ap-hint" id="pay-exp-hint">Month and year, like 08/28.</p>
      <input class="ap-input ap-payment-form__mono" id="pay-exp" name="cc-exp" type="text" inputmode="numeric" autocomplete="cc-exp" spellcheck="false" required aria-describedby="pay-exp-hint" />
      <p class="ap-error ap-payment-form__error" id="pay-exp-error" hidden><svg class="ap-payment-form__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-payment-form__vh">Error:</span> <span data-ap-text></span></p>
    </div>

    <div class="ap-field ap-payment-form__field">
      <div class="ap-payment-form__label-row">
        <label class="ap-label" for="pay-cvc">Security code</label>
        <button type="button" class="ap-payment-form__tip-btn" id="pay-tip-btn" aria-expanded="false">What is this<span class="ap-payment-form__vh"> security code</span>?</button>
      </div>
      <p class="ap-hint" id="pay-cvc-hint" data-ap-cvc-hint>3 digits on the back.</p>
      <span class="ap-payment-form__tip-live" role="status"><span class="ap-payment-form__tip" data-ap-tip hidden>The security code is the last 3 digits printed on the back of your card, beside the signature strip. On American Express cards it is the 4 digits printed on the front.</span></span>
      <input class="ap-input ap-payment-form__mono" id="pay-cvc" name="cc-csc" type="text" inputmode="numeric" autocomplete="cc-csc" maxlength="4" spellcheck="false" required aria-describedby="pay-cvc-hint" />
      <p class="ap-error ap-payment-form__error" id="pay-cvc-error" hidden><svg class="ap-payment-form__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-payment-form__vh">Error:</span> <span data-ap-text></span></p>
    </div>

    <div class="ap-field ap-payment-form__field ap-payment-form__field--wide">
      <label class="ap-label" for="pay-name">Name on card</label>
      <p class="ap-hint" id="pay-name-hint">As it is printed on the card.</p>
      <input class="ap-input" id="pay-name" name="cc-name" type="text" autocomplete="cc-name" spellcheck="false" required aria-describedby="pay-name-hint" />
      <p class="ap-error ap-payment-form__error" id="pay-name-error" hidden><svg class="ap-payment-form__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-payment-form__vh">Error:</span> <span data-ap-text></span></p>
    </div>
  </div>

  <div class="ap-payment-form__foot">
    <p class="ap-payment-form__status" role="status"><svg class="ap-payment-form__icon ap-payment-form__icon--ok" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg><span data-ap-done></span></p>
    <button type="submit" class="ap-btn ap-btn--primary ap-payment-form__pay">
      <svg class="ap-payment-form__spin" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 1 8.5 8.5"/></svg>
      <svg class="ap-payment-form__icon ap-payment-form__paid" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>
      <span data-ap-pay>Pay ₹4,788</span>
    </button>
    <p class="ap-payment-form__demo"><svg class="ap-payment-form__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg><span>A demo: nothing is sent or charged. Try 4242 4242 4242 4242.</span></p>
  </div>
</form>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    Every field has a label above it, hints and errors are in its aria-describedby, and the card brand is part of the number's description.

  • 1.3.5 Identify Input Purpose Level AA

    cc-number, cc-exp, cc-csc and cc-name let the browser or a password manager fill in a saved card.

  • 2.5.3 Label in Name Level A

    The info button reads What is this? and is named What is this security code?, so its visible words start its name.

  • 3.3.1 Error Identification Level A

    A field with a problem is marked aria-invalid, its message appears under it, and the summary lists every one as a link.

  • 3.3.3 Error Suggestion Level AA

    Messages say what to do about the exact problem: how many digits are missing, which month is valid, or that the card has expired.

  • 3.3.4 Error Prevention (Legal, Financial, Data) Level AA

    Nothing is charged until every field passes its checks, the amount is on the button, and the button cannot be pressed twice while it works.

  • 4.1.3 Status Messages Level AA

    The card brand, the note, the progress and the outcome are announced from status regions without moving focus.

Usage

When to use it

Use it

  • Taking a card payment on your own page, where you control the fields.
  • Saving a card for later, with the same fields and no amount.

Use something else

  • Card fields when a payment provider's hosted fields or page are required for security compliance: style theirs and check them the same way.
  • Splitting the card number into four boxes: it breaks autofill and pasting, and moving between boxes confuses everyone.
  • Asking for the card type: it can be worked out from the first digits.

Common failures

How it usually goes wrong

  • Four boxes for the card number

    Separate boxes stop paste and autofill and move focus on their own. One field, spaced as it is typed, reads the same and works with every tool.

  • A caret that jumps to the end

    Reformatting the whole value on every key throws the caret to the end, so a correction in the middle types in the wrong place. The script puts it back after the same digit.

  • type=number for card numbers

    Number inputs drop leading zeros, step with the arrow keys and refuse spaces. type=text with inputmode=numeric brings up the number keypad without those problems.

  • The brand shown only as a logo

    A small logo with no text tells a screen reader nothing and is hard to see. The brand here is a word, part of the field's description and announced once.

  • "Invalid card" and nothing more

    People cannot fix what they are not told. Each message here names the problem: digits missing, a month that does not exist, a card that has expired.

  • A pay button that can be pressed twice

    With no sign that anything is happening, people press again and may be charged twice. The button says Processing your payment and ignores presses until it is done.

Notes

Building it

  • The number is formatted on input, then the caret is put back after as many digits as were before it. Backspace just after a space deletes the digit before it, so the space does not seem to resist.
  • The button uses aria-disabled while it works, not disabled, so it keeps focus and a screen reader can still find it and hear what it says.
  • The security code note follows the toggletip pattern: a button with aria-expanded, and the note written into a status region a moment after the press.
  • The brand is checked from the first digits only: 4 is Visa, 51 to 55 and 2221 to 2720 Mastercard, 34 and 37 American Express, and 60, 65, 81, 82 and 508 RuPay.
  • If your payment provider requires its own hosted fields, keep the labels, hints and messages outside them, and check that autofill and the number keypad still work inside.

Sources: HTML: autofill field names · WAI Tutorials: Validating input · Understanding SC 3.3.4: Error Prevention

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction