Accessibility pattern · Form inputs

Radio cards

Each card is a label wrapped around a real radio button, so a click anywhere on it chooses the plan and the arrow keys move between plans as they do in any radio group. The radio is named by the plan and its price and described by its features, so the card reads in full without the whole card becoming one long name.

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.

Choose a plan

Prices include GST. Change or cancel at any time.

Due today ₹499

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
TabMoves to the chosen plan, or to the first plan when none is chosen; the next Tab leaves the group.
Arrow Down or Arrow RightChooses the next plan, wrapping from the last to the first.
Arrow Up or Arrow LeftChooses the previous plan, wrapping from the first to the last.
SpaceChooses the focused plan when none is chosen yet.
EnterOn a plan, submits the form, the same as pressing Continue.

Screen readers

What it announces

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

WhenExpected announcement
Tab reaches the groupChoose a plan, group. Prices include GST. Studio ₹499 a month, radio button, checked, 2 of 3. Popular. For freelancers and small studios.
Arrow Down chooses the next planTeam ₹1,299 a month, radio button, checked, 3 of 3. For teams of up to ten people. Everything in Studio…
Continue is pressed with nothing chosenFocus moves to Starter: Choose a plan, group. Choose a plan to continue. Starter ₹0 a month, radio button, not checked, 1 of 3
Continue is pressed with a plan chosenPlan chosen: Team

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-radio-cards" data-ap-radio-cards novalidate>
  <fieldset class="ap-radio-cards__set" aria-describedby="radio-cards-hint">
    <legend class="ap-radio-cards__legend">Choose a plan</legend>
    <p class="ap-radio-cards__hint" id="radio-cards-hint">Prices include GST. Change or cancel at any time.</p>
    <p class="ap-error ap-radio-cards__error" id="radio-cards-error" hidden>
      <svg class="ap-radio-cards__error-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.5h.01"/></svg>
      <span>Choose a plan to continue.</span>
    </p>
    <div class="ap-radio-cards__grid">
      <label class="ap-radio-cards__card">
        <input class="ap-radio-cards__radio" type="radio" name="radio-cards-plan" value="starter" aria-labelledby="radio-cards-starter-name radio-cards-starter-price" aria-describedby="radio-cards-starter-fit radio-cards-starter-features" />
        <span class="ap-radio-cards__top">
          <span class="ap-radio-cards__name" id="radio-cards-starter-name">Starter</span>
          <span class="ap-radio-cards__mark" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="m6.5 12.5 3.5 3.5 7.5-8"/></svg></span>
        </span>
        <span class="ap-radio-cards__price" id="radio-cards-starter-price"><span class="ap-radio-cards__amount">₹0</span> <span class="ap-radio-cards__per">a month</span></span>
        <span class="ap-radio-cards__fit" id="radio-cards-starter-fit">For trying it out on your own.</span>
        <span class="ap-radio-cards__features" id="radio-cards-starter-features">
          <span class="ap-radio-cards__feature"><svg class="ap-radio-cards__tick" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>3 projects</span>
          <span class="ap-radio-cards__feature"><svg class="ap-radio-cards__tick" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>2 GB storage</span>
          <span class="ap-radio-cards__feature"><svg class="ap-radio-cards__tick" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Community help</span>
        </span>
      </label>
      <label class="ap-radio-cards__card">
        <input class="ap-radio-cards__radio" type="radio" name="radio-cards-plan" value="studio" aria-labelledby="radio-cards-studio-name radio-cards-studio-price" aria-describedby="radio-cards-studio-badge radio-cards-studio-fit radio-cards-studio-features" checked />
        <span class="ap-radio-cards__top">
          <span class="ap-radio-cards__name" id="radio-cards-studio-name">Studio</span>
          <span class="ap-radio-cards__badge" id="radio-cards-studio-badge"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m12 3.5 2.6 5.3 5.9.9-4.25 4.1 1 5.8L12 16.85 6.75 19.6l1-5.8L3.5 9.7l5.9-.9Z"/></svg>Popular</span>
          <span class="ap-radio-cards__mark" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="m6.5 12.5 3.5 3.5 7.5-8"/></svg></span>
        </span>
        <span class="ap-radio-cards__price" id="radio-cards-studio-price"><span class="ap-radio-cards__amount">₹499</span> <span class="ap-radio-cards__per">a month</span></span>
        <span class="ap-radio-cards__fit" id="radio-cards-studio-fit">For freelancers and small studios.</span>
        <span class="ap-radio-cards__features" id="radio-cards-studio-features">
          <span class="ap-radio-cards__feature"><svg class="ap-radio-cards__tick" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Unlimited projects</span>
          <span class="ap-radio-cards__feature"><svg class="ap-radio-cards__tick" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>100 GB storage</span>
          <span class="ap-radio-cards__feature"><svg class="ap-radio-cards__tick" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Email support within a day</span>
        </span>
      </label>
      <label class="ap-radio-cards__card">
        <input class="ap-radio-cards__radio" type="radio" name="radio-cards-plan" value="team" aria-labelledby="radio-cards-team-name radio-cards-team-price" aria-describedby="radio-cards-team-fit radio-cards-team-features" />
        <span class="ap-radio-cards__top">
          <span class="ap-radio-cards__name" id="radio-cards-team-name">Team</span>
          <span class="ap-radio-cards__mark" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="m6.5 12.5 3.5 3.5 7.5-8"/></svg></span>
        </span>
        <span class="ap-radio-cards__price" id="radio-cards-team-price"><span class="ap-radio-cards__amount">₹1,299</span> <span class="ap-radio-cards__per">a month</span></span>
        <span class="ap-radio-cards__fit" id="radio-cards-team-fit">For teams of up to ten people.</span>
        <span class="ap-radio-cards__features" id="radio-cards-team-features">
          <span class="ap-radio-cards__feature"><svg class="ap-radio-cards__tick" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Everything in Studio</span>
          <span class="ap-radio-cards__feature"><svg class="ap-radio-cards__tick" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Shared folders</span>
          <span class="ap-radio-cards__feature"><svg class="ap-radio-cards__tick" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Phone support</span>
        </span>
      </label>
    </div>
  </fieldset>
  <div class="ap-radio-cards__foot">
    <p class="ap-radio-cards__total"><span class="ap-radio-cards__total-k">Due today</span> <strong class="ap-radio-cards__total-v" data-ap-total translate="no">₹499</strong></p>
    <button type="submit" class="ap-btn ap-btn--primary">Continue</button>
  </div>
  <p class="ap-radio-cards__status" role="status"></p>
</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

    A fieldset and legend group the three plans and name the group, and each radio is tied to its own name, price and features with aria-labelledby and aria-describedby.

  • 1.4.1 Use of Color Level A

    The chosen card gains a filled check mark and a two-pixel edge, so the choice never rests on its tint.

  • 1.4.11 Non-text Contrast Level AA

    The empty check circle and the chosen card's edge both clear 3:1 against the card, so the state of each plan can be seen.

  • 2.1.1 Keyboard Level A

    Native radio buttons answer to Tab and the arrow keys, so the cards need no key handling of their own.

  • 2.4.7 Focus Visible Level AA

    The card that holds keyboard focus shows a ring around its whole edge, drawn when its radio matches :focus-visible.

  • 3.3.1 Error Identification Level A

    Continuing with nothing chosen shows a message in text, adds it to the group's description, and moves focus to the first plan.

  • 4.1.2 Name, Role, Value Level A

    Each plan is a real radio input, so its role, name, checked state and position in the set come from the browser.

Usage

When to use it

Use it

  • A choice between two to four options that each need a few lines of detail, like plans, delivery speeds or seat classes.
  • A decision people compare side by side, where the details help them choose.

Use something else

  • Short options that need no detail: a plain radio group is quicker to scan and read.
  • Choosing several things at once: use checkboxes, styled as cards if you like.
  • Long lists of options: use a select or a combobox.

Common failures

How it usually goes wrong

  • Clickable divs instead of radios

    Cards built from divs with click handlers cannot be reached by Tab, do not answer to the arrow keys and say nothing about being chosen. A real radio inside each card brings all of that.

  • The radio hidden with display: none

    display: none takes the radio out of the Tab order and the accessibility tree, so the cards look fine and work for nobody using a keyboard. Here it is transparent and sits over the check circle.

  • The whole card as the name

    A label wrapped around everything makes the radio's name a paragraph of prices and features. aria-labelledby keeps the name to the plan and price; the rest is its description.

  • Chosen shown only by a tint

    A faint background change is invisible to many people and gone entirely in forced colors mode. The chosen card here also gets a check mark and a heavier edge.

  • No focus ring on the card

    When the real input is invisible, so is its focus ring. The ring is drawn around the card with :has(:focus-visible).

Notes

Building it

  • A label may hold only phrasing content, so everything inside the card is a span set to display as a block; a div or ul there is invalid HTML.
  • Arrow keys in a radio group choose as they move. That suits a picker like this, where choosing changes nothing until the form is sent.
  • The check circle is the radio's own box: the transparent input sits exactly over it, so screen magnifiers and touch exploration find it where the eye does.
  • Leave a sensible plan chosen by default when there is one. The option above starts with none chosen to show the error when Continue is pressed without a choice.
  • The error joins the fieldset's aria-describedby only while it shows: a hidden element that aria-describedby points to is still read out.

Sources: WAI-ARIA Authoring Practices: Radio Group · HTML: the label element

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