Skip to main content

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:

ForegroundBackgroundRatioResult
#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:

  1. Explain task (don't guide them)
  2. Observe without interrupting
  3. Ask them to think aloud
  4. Note friction points
  5. Ask follow-up questions
  6. 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:

PhaseTimelineFocus
Phase 1Week 1-2Critical issues (A/AA)
Phase 2Week 3-4Keyboard navigation
Phase 3Week 5-6Screen reader optimization
Phase 4Week 7-8Testing & 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