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.
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.
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.
ID: cookie-consent-compliance.banner-ux.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:
'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.