Banner is keyboard navigable, screen reader compatible, WCAG 2.1 AA
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.
'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"andaria-labeloraria-labelledbypointing to the banner heading, (2) on mount, focus is moved to the banner (usinguseEffect+ref.current.focus()orautoFocuson 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 likefocus-trap-reactor a custom implementation), (4) all buttons have visible focus indicators (not removed withoutline: noneorfocus:outline-nonewithout a replacement), (5) per-category checkboxes are associated with their labels viahtmlFor/idpairing 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-400onbg-whitetypically fails the 4.5:1 threshold), (7) thelangattribute 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:
'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:2.2 · 4.1.2 — Name, Role, Value — interactive controls must have programmatic name and role
- wcag:2.2 · 2.1.1 — Keyboard — all functionality operable by keyboard
- wcag:2.2 · 1.4.3 — Contrast (Minimum) — 4.5:1 ratio for normal text
- section-508:2018-refresh · 502.3.1 — Objectification — accessible name and role for UI components
Taxons
History
- 2026-04-18·v1.0.0·Initial import from cookie-consent-compliance·automated