# Banner is keyboard navigable, screen reader compatible, WCAG 2.1 AA

- **Pattern:** `ab-000799` (`cookie-consent-compliance.banner-ux.banner-accessible`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000799
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000799)

## Why it matters

A consent banner that is keyboard-inaccessible is functionally invisible to screen reader users and keyboard-only navigators, forcing them to accept cookies by default or abandon the site. WCAG 2.2 SC 2.1.1 requires all functionality to be operable via keyboard; SC 4.1.2 requires interactive controls to have accessible names. Section 508 (2018 refresh 502.3.1) applies the same standard to US government-adjacent products. The legal exposure compounds: a banner that traps or loses keyboard focus also violates WCAG 2.2 SC 2.1.2 (no keyboard trap), creating both accessibility and consent-validity failures simultaneously.

## Severity rationale

Medium because keyboard and screen-reader inaccessibility violates WCAG 2.2 SC 2.1.1 and 4.1.2, but does not directly invalidate the consent mechanism for sighted mouse users who can still interact with it.

## Remediation

Add `role="dialog"`, `aria-modal="true"`, focus management on mount, and a manual focus trap. All checkboxes must have associated labels.

```tsx
'use client'
import { useEffect, useRef } from 'react'

export function CookieBanner({ onSave }: { onSave: (s: ConsentState) => void }) {
  const bannerRef = useRef<HTMLDivElement>(null)
  const firstBtn = useRef<HTMLButtonElement>(null)

  useEffect(() => { firstBtn.current?.focus() }, [])

  function handleKeyDown(e: React.KeyboardEvent) {
    if (e.key !== 'Tab') return
    const els = bannerRef.current?.querySelectorAll<HTMLElement>(
      'button, input, a[href], [tabindex]:not([tabindex="-1"])'
    )
    if (!els?.length) return
    const first = els[0], last = els[els.length - 1]
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
  }

  return (
    <div ref={bannerRef} role="dialog" aria-label="Cookie consent"
         aria-modal="true" onKeyDown={handleKeyDown}
         className="fixed bottom-0 inset-x-0 bg-white border-t p-4">
      <button ref={firstBtn} onClick={() => onSave({ analytics: true, marketing: true })}>
        Accept all
      </button>
      <button onClick={() => onSave({ analytics: false, marketing: false })}>
        Reject all
      </button>
    </div>
  )
}
```

Verify color contrast separately: `text-gray-400` on `bg-white` fails the WCAG 4.5:1 threshold for normal text.

## Detection

- **ID:** `banner-accessible`
- **Severity:** `medium`
- **What to look for:** Read the consent banner component's JSX/HTML. Check for these accessibility signals: (1) the banner container has `role="dialog"` and `aria-label` or `aria-labelledby` pointing to the banner heading, (2) on mount, focus is moved to the banner (using `useEffect` + `ref.current.focus()` or `autoFocus` on the first interactive element), (3) the banner traps focus — pressing Tab cycles through banner controls only, not the underlying page content (look for a focus trap library like `focus-trap-react` or a custom implementation), (4) all buttons have visible focus indicators (not removed with `outline: none` or `focus:outline-none` without a replacement), (5) per-category checkboxes are associated with their labels via `htmlFor`/`id` pairing or wrapping `<label>`, (6) color contrast: verify button text and background color combinations meet 4.5:1 ratio for normal text (check Tailwind color values — e.g., `text-gray-400` on `bg-white` typically fails the 4.5:1 threshold), (7) the `lang` attribute is set on the `<html>` element.
- **Pass criteria:** Banner has `role="dialog"` with a label. Focus moves to the banner on display. Focus is trapped within the banner while it is open. All interactive elements have accessible labels. Focus indicators are visible. Per-category checkboxes are properly labeled. Color contrast meets 4.5:1 minimum. At least 100% of interactive elements must be keyboard-accessible.
- **Fail criteria:** Banner appears as a plain div with no ARIA role. Focus does not move to the banner on display — keyboard users must Tab through the entire page to reach banner controls. No focus trapping — Tab exits the banner into page content. Checkbox labels not associated with inputs. Focus ring removed globally with CSS.
- **Skip (N/A) when:** No consent banner exists (already failing at `banner-first-visit`).
- **Detail on fail:** Specify the accessibility issues found. Example: `"Banner container is a plain div with no role='dialog'. Focus does not move to the banner on display. No focus trapping implemented."` or `"Per-category checkboxes use input type='checkbox' without associated label elements or aria-label attributes."`.
- **Remediation:** Apply accessibility best practices to the banner:

  ```tsx
  'use client'
  import { useEffect, useRef } from 'react'

  export function CookieBanner({ onSave }: { onSave: (state: ConsentState) => void }) {
    const bannerRef = useRef<HTMLDivElement>(null)
    const firstButtonRef = useRef<HTMLButtonElement>(null)

    useEffect(() => {
      // Move focus to the banner when it appears
      firstButtonRef.current?.focus()
    }, [])

    // Simple focus trap — keep focus inside the dialog
    function handleKeyDown(e: React.KeyboardEvent) {
      if (e.key !== 'Tab') return
      const focusable = bannerRef.current?.querySelectorAll<HTMLElement>(
        'button, input, a[href], [tabindex]:not([tabindex="-1"])'
      )
      if (!focusable || focusable.length === 0) return
      const first = focusable[0]
      const last = focusable[focusable.length - 1]
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault()
        last.focus()
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault()
        first.focus()
      }
    }

    return (
      <div
        ref={bannerRef}
        role="dialog"
        aria-label="Cookie consent"
        aria-modal="true"
        onKeyDown={handleKeyDown}
        className="fixed bottom-0 left-0 right-0 bg-white border-t p-4 shadow-lg"
      >
        <h2 id="cookie-dialog-title" className="text-base font-semibold">
          We use cookies
        </h2>
        <p>This site uses cookies to improve your experience.</p>
        <div className="flex gap-3 mt-3">
          <button ref={firstButtonRef} onClick={() => onSave({ analytics: true, marketing: true })}>
            Accept all
          </button>
          <button onClick={() => onSave({ analytics: false, marketing: false })}>
            Reject all
          </button>
        </div>
      </div>
    )
  }
  ```

  For the focus trap, consider using `focus-trap-react` (`npm install focus-trap-react`) to avoid edge cases with custom implementations.

---

## External references

- wcag 4.1.2
- wcag 2.1.1
- wcag 1.4.3
- section-508 502.3.1

Taxons: accessibility

HTML version: https://auditbuffet.com/patterns/ab-000799
