Accessibility Checklist
WCAG 2.1 AA Compliance & Inclusive Design
Document Date: December 13, 2025
1. Overview
Accessibility Standards:
- Target: WCAG 2.1 Level AA
- Scope: All user-facing features
- Testing: Manual + Automated
- Compliance Date: Q1 2026
Why Accessibility Matters:
- 15% of world population has some form of disability
- Legal requirement (ADA, Section 508)
- Better UX for everyone
- SEO benefits
- Larger potential user base
2. Perceivable
2.1 Text Alternatives (Level A)
Guideline 1.1.1 - Non-text Content
Requirements:
- All images have alt text
- Decorative images use empty alt (
alt="") - Complex images have long descriptions
- Icons have accessible labels
- Form inputs have associated labels
- Buttons have descriptive text or aria-label
Implementation:
// ✅ Good: Image with alt text
<Image
src="/robot-illustration.png"
alt="Friendly robot helping a student with homework"
width={400}
height={300}
/>
// ✅ Good: Decorative image
<Image
src="/background-pattern.svg"
alt=""
aria-hidden="true"
width={1200}
height={800}
/>
// ✅ Good: Icon button with label
<button aria-label="Close modal" onClick={onClose}>
<XIcon className="w-5 h-5" />
</button>
// ❌ Bad: Missing alt text
<img src="/diagram.png" />
// ❌ Bad: Generic alt text
<img src="/chart.png" alt="Image" />
Complex Images:
// Use aria-describedby for charts/diagrams
<div>
<HighchartsReact
highcharts={Highcharts}
options={chartOptions}
aria-label="Score trend chart"
aria-describedby="chart-description"
/>
<div id="chart-description" className="sr-only">
Your scores have improved from 65% in January to 88% in March,
with a consistent upward trend. The target score of 80% was
reached in February and maintained since then.
</div>
</div>
// Screen reader only utility class
// Add to globals.css
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
2.2 Color Contrast (Level AA)
Guideline 1.4.3 - Contrast (Minimum)
Requirements:
- Normal text: 4.5:1 contrast ratio
- Large text (18px+ or 14px+ bold): 3:1 ratio
- UI components: 3:1 ratio
- Active/focus states: clearly visible
- Don't rely on color alone for information
Color Combinations Audit:
// Create contrast testing utility
// /lib/accessibility/contrast.ts
export function getLuminance(hex: string): number {
const rgb = parseInt(hex.slice(1), 16)
const r = (rgb >> 16) & 0xff
const g = (rgb >> 8) & 0xff
const b = (rgb >> 0) & 0xff
const [rs, gs, bs] = [r, g, b].map((c) => {
c = c / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
}
export function getContrastRatio(fg: string, bg: string): number {
const l1 = getLuminance(fg)
const l2 = getLuminance(bg)
const lighter = Math.max(l1, l2)
const darker = Math.min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
}
export function meetsWCAG(
fg: string,
bg: string,
level: 'AA' | 'AAA' = 'AA',
size: 'normal' | 'large' = 'normal'
): boolean {
const ratio = getContrastRatio(fg, bg)
if (level === 'AAA') {
return size === 'large' ? ratio >= 4.5 : ratio >= 7
}
return size === 'large' ? ratio >= 3 : ratio >= 4.5
}
// Usage in development
if (process.env.NODE_ENV === 'development') {
const passes = meetsWCAG('#64748B', '#FFFFFF', 'AA', 'normal')
if (!passes) {
console.warn('Color contrast fails WCAG AA')
}
}
Verified Color Combinations:
| Foreground | Background | Ratio | Result |
|---|---|---|---|
| #1E293B (slate-900) | #FFFFFF (white) | 16.1:1 | ✅ AAA |
| #475569 (slate-600) | #FFFFFF (white) | 7.5:1 | ✅ AAA |
| #64748B (slate-500) | #FFFFFF (white) | 4.9:1 | ✅ AA |
| #94A3B8 (slate-400) | #FFFFFF (white) | 2.9:1 | ❌ Fails |
| #FFFFFF (white) | #2563EB (blue-600) | 5.1:1 | ✅ AA |
| #1D4ED8 (blue-700) | #EFF6FF (blue-50) | 8.2:1 | ✅ AAA |
Information Beyond Color:
// ❌ Bad: Color only
<span className="text-red-600">Error</span>
// ✅ Good: Icon + color + text
<span className="text-red-600 flex items-center gap-1">
<AlertIcon className="w-4 h-4" />
<span className="font-semibold">Error:</span> Invalid email format
</span>
// ❌ Bad: Status indicated by color only
<div className="bg-green-100">Completed</div>
<div className="bg-yellow-100">In Progress</div>
// ✅ Good: Icon + text + color
<div className="bg-green-100 flex items-center gap-2">
<CheckIcon className="w-5 h-5 text-green-700" />
<span className="font-semibold text-green-700">Completed</span>
</div>
2.3 Adaptable Content (Level A)
Guideline 1.3.1 - Info and Relationships
Requirements:
- Semantic HTML structure
- Proper heading hierarchy (h1 → h2 → h3)
- Form labels programmatically associated
- Lists use proper markup (ul, ol, dl)
- Tables have proper headers
Implementation:
// ✅ Good: Semantic HTML
<main>
<h1>Student Dashboard</h1>
<section aria-labelledby="assignments-heading">
<h2 id="assignments-heading">Your Assignments</h2>
<article>
<h3>Assignment 1</h3>
<p>Description...</p>
</article>
</section>
</main>
// ❌ Bad: Div soup
<div className="dashboard">
<div className="title">Student Dashboard</div>
<div className="section">
<div className="heading">Your Assignments</div>
</div>
</div>
// ✅ Good: Form labels
<div>
<label htmlFor="email" className="block mb-1">
Email Address
</label>
<input
id="email"
type="email"
aria-required="true"
aria-describedby="email-hint"
/>
<span id="email-hint" className="text-sm">
We'll never share your email
</span>
</div>
// ❌ Bad: No label association
<div>
<span>Email</span>
<input type="email" />
</div>
3. Operable
3.1 Keyboard Accessible (Level A)
Guideline 2.1.1 - Keyboard
Requirements:
- All functionality available via keyboard
- Logical tab order
- No keyboard traps
- Skip to main content link
- Visible focus indicators
Focus Management:
// /components/ui/FocusTrap.tsx
import { useEffect, useRef } from 'react'
export function FocusTrap({ children, active }: { children: React.ReactNode; active: boolean }) {
const containerRef = useRef<HTMLDivElement>(null)
const previousFocus = useRef<HTMLElement | null>(null)
useEffect(() => {
if (!active) return
// Save current focus
previousFocus.current = document.activeElement as HTMLElement
// Get all focusable elements
const focusableElements = containerRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (!focusableElements || focusableElements.length === 0) return
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
// Focus first element
firstElement.focus()
// Handle Tab key
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement.focus()
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement.focus()
}
}
}
document.addEventListener('keydown', handleTab)
return () => {
document.removeEventListener('keydown', handleTab)
// Restore previous focus
previousFocus.current?.focus()
}
}, [active])
return <div ref={containerRef}>{children}</div>
}
// Usage in Modal
export function Modal({ isOpen, onClose, children }) {
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal content */}
<FocusTrap active={isOpen}>
<div className="relative z-10 max-w-lg mx-auto mt-20 bg-white rounded-xl p-6">
{children}
<button
onClick={onClose}
className="absolute top-4 right-4"
aria-label="Close modal"
>
<XIcon className="w-5 h-5" />
</button>
</div>
</FocusTrap>
</div>
)}
</AnimatePresence>
)
}
Skip Links:
// /components/SkipLink.tsx
export function SkipLink() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-stem-blue-600 focus:text-white focus:rounded-lg focus:shadow-lg"
>
Skip to main content
</a>
)
}
// Add to layout
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<SkipLink />
<Navigation />
<main id="main-content" tabIndex={-1}>
{children}
</main>
<Footer />
</body>
</html>
)
}
Focus Visible Styles:
/* globals.css */
@layer utilities {
.focus-ring {
@apply focus-visible:outline-none
focus-visible:ring-2
focus-visible:ring-stem-blue-500
focus-visible:ring-offset-2;
}
/* Ensure focus is visible on all interactive elements */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
@apply ring-2 ring-stem-blue-500 ring-offset-2 outline-none;
}
}
3.2 Enough Time (Level A)
Guideline 2.2.1 - Timing Adjustable
Requirements:
- No time limits (or allow user to extend)
- Auto-save for forms
- Session timeout warnings
Implementation:
// /components/SessionTimeout.tsx
import { useState, useEffect } from 'react'
export function SessionTimeout({ timeoutMinutes = 30 }) {
const [showWarning, setShowWarning] = useState(false)
const [timeLeft, setTimeLeft] = useState(0)
useEffect(() => {
let warningTimer: NodeJS.Timeout
let logoutTimer: NodeJS.Timeout
const resetTimers = () => {
clearTimeout(warningTimer)
clearTimeout(logoutTimer)
// Show warning 5 minutes before timeout
warningTimer = setTimeout(() => {
setShowWarning(true)
setTimeLeft(5 * 60) // 5 minutes in seconds
}, (timeoutMinutes - 5) * 60 * 1000)
// Auto logout after timeout
logoutTimer = setTimeout(() => {
logout()
}, timeoutMinutes * 60 * 1000)
}
// Reset timers on user activity
const events = ['mousedown', 'keydown', 'scroll', 'touchstart']
events.forEach((event) => {
document.addEventListener(event, resetTimers)
})
resetTimers()
return () => {
clearTimeout(warningTimer)
clearTimeout(logoutTimer)
events.forEach((event) => {
document.removeEventListener(event, resetTimers)
})
}
}, [timeoutMinutes])
// Countdown timer
useEffect(() => {
if (!showWarning) return
const interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [showWarning])
if (!showWarning) return null
return (
<Modal isOpen={showWarning} onClose={() => setShowWarning(false)}>
<h2 className="text-xl font-bold mb-4">Session Expiring Soon</h2>
<p className="mb-4">
Your session will expire in {Math.floor(timeLeft / 60)} minutes.
Would you like to continue?
</p>
<div className="flex gap-4">
<Button
variant="primary"
onClick={() => {
setShowWarning(false)
// Refresh session
}}
>
Continue Session
</Button>
<Button variant="secondary" onClick={logout}>
Logout
</Button>
</div>
</Modal>
)
}
3.3 Navigable (Level A)
Guideline 2.4.1 - Bypass Blocks
Requirements:
- Skip navigation links
- Logical heading structure
- Descriptive page titles
- Focus order follows visual order
- Link purpose clear from context
Page Titles:
// /app/student/dashboard/page.tsx
export const metadata = {
title: 'Student Dashboard | STEMBlock.ai',
description: 'View your assignments, track progress, and see your scores',
}
// Dynamic titles
import { Metadata } from 'next'
export async function generateMetadata({ params }): Promise<Metadata> {
const assignment = await fetchAssignment(params.id)
return {
title: `${assignment.title} | STEMBlock.ai`,
description: assignment.description,
}
}
Descriptive Links:
// ❌ Bad: Generic link text
<Link href="/assignments/123">Click here</Link>
<Link href="/assignments/123">Read more</Link>
// ✅ Good: Descriptive link text
<Link href="/assignments/123">View Robot Design Assignment</Link>
<Link href="/assignments/123" aria-label="Read more about Robot Design Assignment">
Read more
</Link>
4. Understandable
4.1 Readable (Level A)
Guideline 3.1.1 - Language of Page
// /app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en"> {/* Required */}
<body>{children}</body>
</html>
)
}
// For multilingual content
<p lang="es">Hola, bienvenido a STEMBlock.ai</p>
4.2 Predictable (Level A)
Guideline 3.2.1 - On Focus
Requirements:
- Focus doesn't trigger unexpected actions
- Consistent navigation across pages
- Consistent component behavior
// ❌ Bad: Auto-submit on focus
<input onFocus={handleSubmit} />
// ✅ Good: Explicit submit button
<form onSubmit={handleSubmit}>
<input type="text" />
<button type="submit">Submit</button>
</form>
// ❌ Bad: Inconsistent navigation
// Page 1
<nav>
<Link href="/dashboard">Dashboard</Link>
<Link href="/settings">Settings</Link>
</nav>
// Page 2
<nav>
<Link href="/settings">Settings</Link>
<Link href="/dashboard">Dashboard</Link>
</nav>
// ✅ Good: Consistent order
// All pages
<nav>
<Link href="/dashboard">Dashboard</Link>
<Link href="/assignments">Assignments</Link>
<Link href="/settings">Settings</Link>
</nav>
4.3 Input Assistance (Level A)
Guideline 3.3.1 - Error Identification
Requirements:
- Clear error messages
- Errors announced to screen readers
- Error prevention for critical actions
// Form with comprehensive error handling
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm({
resolver: zodResolver(schema),
})
const onSubmit = async (data) => {
try {
await login(data)
} catch (error) {
if (error.code === 'INVALID_CREDENTIALS') {
setError('root', {
message: 'Invalid email or password. Please try again.',
})
}
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* Form-level error */}
{errors.root && (
<div
role="alert"
className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg"
>
<p className="text-red-700 font-semibold flex items-center gap-2">
<AlertIcon className="w-5 h-5" />
{errors.root.message}
</p>
</div>
)}
<Input
label="Email Address"
type="email"
error={errors.email?.message}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
{...register('email')}
/>
<Input
label="Password"
type="password"
error={errors.password?.message}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
{...register('password')}
/>
<Button
type="submit"
loading={isSubmitting}
disabled={isSubmitting}
fullWidth
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</form>
)
}
5. Robust
5.1 Compatible (Level A)
Guideline 4.1.2 - Name, Role, Value
Requirements:
- Valid HTML
- ARIA roles used correctly
- Dynamic content updates announced
// Custom components with proper ARIA
export function Tabs({ tabs, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<div>
{/* Tab List */}
<div role="tablist" aria-label="Assignment tabs">
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={clsx(
'px-4 py-2',
activeTab === tab.id && 'border-b-2 border-blue-500'
)}
>
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
{tabs.map((tab) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== tab.id}
>
{tab.content}
</div>
))}
</div>
)
}
// Live regions for dynamic updates
export function NotificationToast({ message }) {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="fixed bottom-4 right-4 bg-white shadow-lg rounded-lg p-4"
>
{message}
</div>
)
}
// Alert for critical updates
export function ErrorAlert({ message }) {
return (
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
className="bg-red-50 border border-red-200 rounded-lg p-4"
>
{message}
</div>
)
}
6. Testing Checklist
6.1 Automated Testing
Tools:
- axe DevTools browser extension
- Lighthouse accessibility audit
- pa11y CI integration
- jest-axe for component tests
# Install testing tools
npm install --save-dev @axe-core/react jest-axe
# Run automated tests
npm run test:a11y
// /tests/accessibility.test.tsx
import { axe, toHaveNoViolations } from 'jest-axe'
import { render } from '@testing-library/react'
import { Button } from '@/components/ui/Button'
expect.extend(toHaveNoViolations)
describe('Button Accessibility', () => {
it('should have no accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
it('should have accessible name', () => {
const { getByRole } = render(<Button>Submit</Button>)
expect(getByRole('button', { name: 'Submit' })).toBeInTheDocument()
})
it('should be keyboard accessible', () => {
const handleClick = jest.fn()
const { getByRole } = render(<Button onClick={handleClick}>Click</Button>)
const button = getByRole('button')
button.focus()
expect(button).toHaveFocus()
// Simulate Enter key
button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(handleClick).toHaveBeenCalled()
})
})
6.2 Manual Testing
Screen Reader Testing:
- NVDA (Windows) - Free
- JAWS (Windows) - Paid
- VoiceOver (macOS/iOS) - Built-in
- TalkBack (Android) - Built-in
Keyboard Testing:
- Tab through all interactive elements
- Shift+Tab reverse order
- Enter/Space activate buttons/links
- Arrow keys for custom widgets
- Escape closes modals/dropdowns
Visual Testing:
- Zoom to 200% (no horizontal scroll)
- Browser zoom to 400%
- Dark mode / High contrast mode
- Color blindness simulation
6.3 User Testing
Recruit Users With Disabilities:
- Screen reader users
- Keyboard-only users
- Users with low vision
- Users with motor impairments
- Users with cognitive disabilities
Testing Protocol:
- Explain task (don't guide them)
- Observe without interrupting
- Ask them to think aloud
- Note friction points
- Ask follow-up questions
- Document findings
7. Compliance Documentation
Create Accessibility Statement:
# Accessibility Statement for STEMBlock.ai
## Commitment
STEMBlock.ai is committed to ensuring digital accessibility for people with disabilities. We are continually improving the user experience for everyone and applying the relevant accessibility standards.
## Conformance Status
The Web Content Accessibility Guidelines (WCAG) defines requirements for designers and developers to improve accessibility for people with disabilities. It defines three levels of conformance: Level A, Level AA, and Level AAA. STEMBlock.ai is partially conformant with WCAG 2.1 level AA.
## Feedback
We welcome your feedback on the accessibility of STEMBlock.ai. Please let us know if you encounter accessibility barriers:
- Email: accessibility@stemblockai.com
- Phone: [phone number]
We try to respond to feedback within 2 business days.
## Technical Specifications
STEMBlock.ai relies on the following technologies to work with the combination of web browser and any assistive technologies or plugins installed on your computer:
- HTML
- WAI-ARIA
- CSS
- JavaScript
These technologies are relied upon for conformance with the accessibility standards used.
## Limitations and Alternatives
Despite our best efforts to ensure accessibility of STEMBlock.ai, there may be some limitations. Below is a description of known limitations, and potential solutions:
- Third-party content: Some embedded content may not be fully accessible. We are working with providers to improve this.
- Legacy features: Some older features may not meet current standards. These are being updated on a rolling basis.
## Assessment Approach
STEMBlock.ai assessed the accessibility of this website by the following approaches:
- Self-evaluation
- External evaluation by [accessibility consultant]
- Automated testing with [tools used]
- User testing with people with disabilities
## Date
This statement was created on December 13, 2025, and last updated on December 13, 2025.
Summary
Accessibility Compliance Roadmap:
| Phase | Timeline | Focus |
|---|---|---|
| Phase 1 | Week 1-2 | Critical issues (A/AA) |
| Phase 2 | Week 3-4 | Keyboard navigation |
| Phase 3 | Week 5-6 | Screen reader optimization |
| Phase 4 | Week 7-8 | Testing & documentation |
Expected Outcomes:
- ✅ WCAG 2.1 AA compliance
- ✅ Lighthouse accessibility score 95+
- ✅ Zero critical accessibility issues
- ✅ Positive user testing with assistive tech users
Document Version: 1.0 Last Updated: December 13, 2025