DEV Community

One line of CSS that breaks keyboard accessibility sitewide and two other patterns we keep finding in audits

Pattern 1 - One line of CSS that removes focus indicators sitewide

The most common keyboard accessibility failure we see: outline: none

This is the one that surprises people the most, because it's so small. A developer adds this somewhere in the global stylesheet, usually to clean up the default browser focus ring, which can look inconsistent across browsers:

a:focus { outline: none; }
button:focus { outline: 0; }

Sometimes it's scoped to a component. Sometimes it's applied globally. Either way, the result is the same: every keyboard user navigating your site loses their only visual indicator of where they are on the page. Tab still works. Focus is still moving. The user just can't see it.

For someone navigating with a keyboard due to a motor disability, this is the equivalent of removing the cursor from a mouse user's screen. They can keep clicking, but they have no idea where.

This fails WCAG 2.4.7 (Focus Visible) and WCAG 2.4.11 (Focus Appearance) at the AA level.

The fix

Don't remove the outline. Style it instead.

/* Remove this */
a:focus { outline: none; }
button:focus { outline: 0; }

/* Replace with this */
:focus-visible {
  outline: 2px solid #0057b7;
  outline-offset: 2px;
}

Using :focus-visible instead of :focus gives you the best of both worlds: the focus indicator appears for keyboard navigation but not for mouse clicks, which is the behavior most design systems want. The browser handles the distinction natively.

Two things to verify after applying this fix: the outline color meets a 3:1 contrast ratio against the adjacent background (WCAG 1.4.11), and the outline is visible against both light and dark backgrounds if your site uses both.

One line caused the problem. One rule fixes it sitewide.

Pattern 2 - Interactive elements built with the wrong HTML that never enter the tab order

If it's not natively focusable, keyboard users can't reach it.

This one appears most often in navigation components, menus, toggles, icon buttons, where a developer reaches for a <div> or an <a> without an href because it felt like the right container for the visual design. Here's a real pattern we documented. A hamburger menu trigger built like this:

<a class="nav-btn js-nav-btn-menu" data-toggle="dropdown" data-display="static">
  <svg width="26" height="26" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path d="M1.71436 14.8571H30.2858" stroke="#003680" stroke-width="2" stroke-linecap="round"/>
    <path d="M1.71436 25.1428H30.2858" stroke="#003680" stroke-width="2" stroke-linecap="round"/>
    <path d="M1.71436 4.57141H30.2858" stroke="#003680" stroke-width="2" stroke-linecap="round"/>
  </svg>
</a>

The problem: an <a> element without an href attribute is not focusable by default. It exists in the DOM, it's visible on screen, it works with a mouse click via JavaScript, but Tab navigation skips it entirely. The SVG has no role, no accessible name, no keyboard interaction. A keyboard user cannot open the navigation menu at all.

On a mobile-first site where this menu is the primary navigation, that's a complete blocker. This fails WCAG 2.1.1 (Keyboard) at the AA level.

The fix

Use a native <button> element. It's focusable by default, activatable with Enter and Space, and requires no extra ARIA to work correctly:

<button class="nav-btn js-nav-btn-menu" aria-expanded="false" aria-controls="main-nav" aria-label="Open navigation menu">
  <svg width="26" height="26" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
    <path d="M1.71436 14.8571H30.2858" stroke="#003680" stroke-width="2" stroke-linecap="round"/>
    <path d="M1.71436 25.1428H30.2858" stroke="#003680" stroke-width="2" stroke-linecap="round"/>
    <path d="M1.71436 4.57141H30.2858" stroke="#003680" stroke-width="2" stroke-linecap="round"/>
  </svg>
</button>
const navBtn = document.querySelector('.nav-btn');
const mainNav = document.getElementById('main-nav');

navBtn.addEventListener('click', () => {
  const isExpanded = navBtn.getAttribute('aria-expanded') === 'true';
  navBtn.setAttribute('aria-expanded', !isExpanded);
  mainNav.hidden = isExpanded;

  // Update label to reflect current state
  navBtn.setAttribute('aria-label', isExpanded ? 'Open navigation menu' : 'Close navigation menu');
});

Three things the fix adds that the original lacked: native keyboard focus, a name that screen readers can announce, and a state (aria-expanded) that tells assistive technology whether the menu is open or closed. The SVG gets aria-hidden="true" and focusable="false" because the button's aria-label already provides the accessible name - the icon is decorative in this context.

Pattern 3 - Form controls that are visually present but keyboard-unreachable

The form field keyboard users cannot interact with - even when it looks completely normal.

This pattern is the most damaging from a conversion standpoint, because it appears inside forms - the exact flows where users need to complete a transaction, submit a request, or finish a registration. The specific case we documented: a date of birth selector in a quote request form. Visually it renders as a date picker. Functionally it's implemented like this:

<div>Select date of birth</div>
<input type="hidden" id="dob" name="dob" />

The <div> has no tabindex, no role, no keyboard event handlers. Tab skips it. Enter does nothing. The type="hidden" input is intentionally invisible to the browser's accessibility tree. A keyboard user reaches this step and has no way forward. The form cannot be completed.

This fails WCAG 2.1.1 (Keyboard) at the Critical severity level - it's not a degraded experience, it's a complete blocker. This specific pattern appears frequently in date pickers, custom dropdowns, color pickers, and any component where a developer uses a hidden input to store the value while a custom UI element handles the visual presentation. The custom UI element almost never has keyboard support.

The fix

The trigger needs to be a real interactive element with keyboard support, an accessible name, and ARIA attributes that communicate its current state:

<button id="dob-trigger" aria-haspopup="dialog" aria-label="Date of birth, no date selected" aria-expanded="false">
  Select date of birth
</button>
<input type="text" id="dob-field" name="dob" aria-label="Date of birth" placeholder="MM/DD/YYYY" autocomplete="bday">
const dobTrigger = document.getElementById('dob-trigger');
const dobField = document.getElementById('dob-field');

dobTrigger.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();
    openDatepicker();
  }
});

function onDateSelected(date) {
  const formatted = formatDate(date); // e.g. "06/15/1990"
  dobField.value = formatted;

  // Update the trigger label to reflect selection
  dobTrigger.setAttribute(
    'aria-label',
    `Date of birth, ${formatted} selected. Press Enter to change.`
  );

  // Return focus to trigger after selection
  dobTrigger.focus();
}

The key principle: whatever value the user selects must be reflected in the accessible name of the trigger, so screen reader users know what they've chosen without having to navigate away from the component. If you're using a third-party date picker library, check whether it exposes keyboard navigation and ARIA attributes before integrating it. Most don't by default.

Why these three keep appearing

These aren't obscure edge cases. They're the result of three patterns that are extremely common in how front-end development gets prioritized:

  • The focus indicator gets removed because the default browser outline "looks bad" and replacing it properly gets deprioritized.
  • The accessible name and keyboard support get skipped because the component works visually and passes a quick manual click-through.
  • The form control gets built with a hidden input because that's the easiest way to decouple the visual presentation from the data layer, and accessibility testing never reaches the keyboard interaction path.

None of these are malicious decisions. They're prioritization gaps, and they compound: a site with all three patterns effectively locks out every keyboard user from navigating, finding the menu, and completing a form.

What static scanners won't catch

axe and Lighthouse will flag missing alt text and low color contrast reliably. They will not catch any of the three patterns above. The outline: none rule sits in CSS - scanners don't simulate keyboard navigation to verify whether focus is visible. The <a> without href may not trigger a violation depending on how the tool evaluates focusability. The hidden input pattern passes most automated checks because the element is intentionally hidden and the visible trigger has no semantic role to flag.

Manual keyboard testing - Tab through every interactive element, verify focus is visible, verify every control responds to Enter and Space - is the only reliable way to catch these. If you want to run a quick check on a live URL without a full manual audit, A11yDetector navigates tab by tab and surfaces exactly this class of issue.

Three different sites. Three different stacks. The same three failures. Keyboard accessibility breaks in predictable ways. The patterns are consistent enough that once you know what to look for, you'll spot them in minutes on almost any site you audit. The fixes are equally consistent: use semantic HTML, don't suppress focus styles, and test with a keyboard before shipping.

Comments

No comments yet. Start the discussion.