The feedback is fair, and it points at a real gap — not broken semantics, but missing keyboard affordance and visible focus.
Your HTML foundation is sound. Real <input type="radio"> elements inside <label> wrappers is the right pattern: screen readers get a proper radio group, arrow keys work natively, and form submission behaves correctly. An automated checker can pass that and still miss the sighted keyboard-user experience.
The reviewer is describing that gap. Custom cards that look like clickable tiles don’t read as “radio group” to someone tabbing through the form. That’s an operability and perceivability problem, not a semantic one.
Native radio groups behave like this:
- Tab moves into the group (to the selected radio, or the first one if nothing is selected).
- Tab again skips the other radios and goes to the next control (Submit).
- Arrow keys move between options in the group.
That Tab behavior is correct per the spec, but your demo gives users almost no signal for it. The radios are hidden with zero size and no opacity:
.custom-card input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}There’s also no :focus or :focus-within styling on the cards. A sighted keyboard user can land on Option 1, press Tab expecting Option 2, jump straight to Submit, and never see which card had focus. Arrow keys may work, but nothing suggests they should try them.
So: technically functional, practically confusing for that audience.
When the hidden radio inside a label has focus, style the whole card:
.custom-card:focus-within {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
.custom-card:focus-within:not(:has(input:checked)) {
border-color: #005fcc;
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.25);
}This alone fixes “I tabbed here and have no idea where I am.”
Wrap the options so the group has a name and context:
<fieldset>
<legend>Choose a cat</legend>
<!-- your custom-card labels -->
</fieldset>Screen readers announce the group; sighted users get a clear heading for the choice block.
One line above or below the cards:
Use Tab to reach this choice, then arrow keys to switch options.
You shouldn’t rely on this alone, but it sets expectations for the non-obvious Tab vs. arrow behavior. aria-describedby can tie it to the group for assistive tech.
You don’t need to restore default radio chrome. A corner circle, checkmark when selected, or “Selected” badge helps users map cards to “pick one of several.” Selection state is only a blue background today, which is weaker for color vision and for understanding control type.
5. Prefer a proper visually-hidden pattern over width: 0; height: 0
Clip-based hiding keeps inputs reliably focusable and is a well-known pattern:
.custom-card input[type="radio"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}Pair that with :focus-within on the label so focus is always visible on the card, not on a 1×1 pixel.
Making each card a separate tab stop (e.g. tabindex="0" on labels) would break expected radio keyboard behavior. Keep native radios; improve focus visibility and set expectations instead.
Something like:
The pattern uses real radio inputs for semantics and form behavior. Custom styling hides the native control, so we add
:focus-withinon each card, group the options in afieldset, and include a brief keyboard hint. Arrow keys switch options; Tab moves to the next field — standard radio behavior that’s easy to miss when the UI looks like generic cards.
That shows you’re addressing operability, not just checkbox-audit semantics.
| Concern | Valid? | Fix |
|---|---|---|
| Doesn’t look like radios | Yes | Focus ring, optional radio/check indicator |
| Arrow keys not suggested | Yes | Hint text + fieldset/legend |
| Tab doesn’t reach 2nd option | Partially — that’s correct radio behavior | Explain it; don’t change tab order |
| Semantics wrong | No | Keep current HTML pattern |
I’d treat this as “good foundation, needs keyboard UX polish,” not “start over.” The changes are mostly CSS and a bit of markup — still no JavaScript.
If you want, I can apply these updates directly in index.html and style.css so the demo matches what you’d recommend in a follow-up post.