PDF Accessibility and WCAG Compliance: Making Your Documents Work for Everyone ♿
June 13, 2025
Your beautifully designed PDF looks perfect. But 15% of your users can't read it.
Why? Because you didn't think about accessibility.
- Screen readers can't parse the content
- Keyboard users can't navigate
- Visually impaired users can't read the text
- Color-blind users can't distinguish important information
One lawsuit later, you're learning about ADA compliance the hard way.
This guide shows you how to create accessible, WCAG-compliant PDFs from day one - so everyone can use your documents.
Why PDF Accessibility Matters
The Legal Reality
You can get sued for inaccessible PDFs.
- ADA Title III (US): Requires digital accessibility
- Section 508 (US Government): Mandates accessible documents
- European Accessibility Act: EU-wide requirements
- AODA (Ontario, Canada): Accessibility compliance required
Recent lawsuits:
- Domino's Pizza: $4,000 per inaccessible document
- Harvard & MIT: Class action for inaccessible course materials
- Banks, retailers, universities: Hundreds of cases annually
The Moral Reality
1 billion people worldwide have disabilities.
When your PDFs aren't accessible:
- Blind users can't access invoices
- Dyslexic users struggle to read reports
- Motor-impaired users can't fill out forms
- Color-blind users miss critical information
Accessibility isn't optional. It's essential.
Understanding WCAG 2.1 for PDFs
The Four Principles (POUR)
1. Perceivable - Information must be presentable in ways users can perceive 2. Operable - Interface components must be operable 3. Understandable - Information must be understandable 4. Robust - Content must work with assistive technologies
Three Conformance Levels
- Level A - Minimum (you'll get sued if you don't meet this)
- Level AA - Recommended (industry standard)
- Level AAA - Enhanced (gold standard)
Target: WCAG 2.1 Level AA for all PDFs
Making PDFs Accessible: The Technical Implementation
1. Document Structure and Semantics
The Problem: PDFs without structure are just images to screen readers.
<!-- ❌ No semantic structure -->
<p style="font-size: 24px; font-weight: bold;">Invoice</p>
<p style="font-size: 18px; font-weight: bold;">Customer Information</p>
<p>John Doe</p>The Solution: Use proper HTML5 semantic tags.
<!-- ✅ Proper semantic structure -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Invoice #12345 - Acme Corp</title>
</head>
<body>
<main>
<header>
<h1>Invoice #12345</h1>
</header>
<section aria-labelledby="customer-heading">
<h2 id="customer-heading">Customer Information</h2>
<dl>
<dt>Name:</dt>
<dd>John Doe</dd>
<dt>Email:</dt>
<dd>[email protected]</dd>
</dl>
</section>
<section aria-labelledby="items-heading">
<h2 id="items-heading">Items</h2>
<table>
<caption>Invoice line items</caption>
<thead>
<tr>
<th scope="col">Description</th>
<th scope="col">Quantity</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr>
<td>Consulting Services</td>
<td>10 hours</td>
<td>$1,500</td>
</tr>
</tbody>
</table>
</section>
</main>
</body>
</html>2. Heading Hierarchy
The Problem: Broken heading structure confuses screen readers.
<!-- ❌ Broken hierarchy -->
<h1>Invoice</h1>
<h3>Customer Details</h3> <!-- Skipped h2 -->
<h2>Line Items</h2> <!-- Wrong order -->The Solution: Logical heading order (h1 → h2 → h3).
<!-- ✅ Proper hierarchy -->
<h1>Invoice #12345</h1>
<h2>Customer Information</h2>
<h3>Billing Address</h3>
<h3>Shipping Address</h3>
<h2>Line Items</h2>
<h2>Payment Information</h2>3. Alternative Text for Images
The Problem: Images without alt text are invisible to screen readers.
<!-- ❌ No alt text -->
<img src="logo.png">
<img src="chart.png">The Solution: Descriptive alt text for all images.
<!-- ✅ Descriptive alt text -->
<img src="logo.png" alt="Acme Corporation logo">
<img src="revenue-chart.png" alt="Bar chart showing monthly revenue growth from January to June 2025. Revenue increased from $10,000 in January to $25,000 in June.">
<!-- For decorative images -->
<img src="decorative-border.png" alt="" role="presentation">Alt text guidelines:
- Informative images: Describe the content/purpose
- Complex images (charts): Provide detailed description
- Decorative images: Use
alt=""androle="presentation" - Logos: Include company name
- Avoid: "image of...", "picture of..."
4. Accessible Tables
The Problem: Tables without headers are incomprehensible to screen readers.
<!-- ❌ No table structure -->
<table>
<tr>
<td>Description</td>
<td>Price</td>
</tr>
<tr>
<td>Widget</td>
<td>$10</td>
</tr>
</table>The Solution: Proper table markup with headers and scope.
<!-- ✅ Accessible table -->
<table>
<caption>Invoice Line Items - Total: $1,500</caption>
<thead>
<tr>
<th scope="col">Item Description</th>
<th scope="col">Quantity</th>
<th scope="col">Unit Price</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Consulting Services</th>
<td>10 hours</td>
<td>$150/hour</td>
<td>$1,500</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="3">Grand Total</th>
<td>$1,500</td>
</tr>
</tfoot>
</table>For complex tables:
<table>
<caption>Quarterly Sales by Region</caption>
<thead>
<tr>
<th id="region" scope="col">Region</th>
<th id="q1" scope="col">Q1 2025</th>
<th id="q2" scope="col">Q2 2025</th>
</tr>
</thead>
<tbody>
<tr>
<th id="north" scope="row">North</th>
<td headers="north q1">$50,000</td>
<td headers="north q2">$65,000</td>
</tr>
</tbody>
</table>5. Color and Contrast
The Problem: Information conveyed only by color is inaccessible.
<!-- ❌ Color-only indication -->
<style>
.status-paid { color: green; }
.status-overdue { color: red; }
</style>
<p class="status-paid">Paid</p>
<p class="status-overdue">Overdue</p>
<!-- Color-blind users can't distinguish -->The Solution: Use text, icons, or patterns in addition to color.
<!-- ✅ Multiple indicators -->
<style>
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-weight: 600;
}
.status-paid {
background: #dcfce7;
color: #166534;
}
.status-paid::before {
content: "✓ ";
}
.status-overdue {
background: #fee2e2;
color: #991b1b;
}
.status-overdue::before {
content: "! ";
}
</style>
<span class="status-badge status-paid">Paid</span>
<span class="status-badge status-overdue">Overdue</span>
<!-- Text + icon + color = accessible -->Contrast Requirements (WCAG AA):
- Normal text: 4.5:1 minimum
- Large text (18px+): 3:1 minimum
- UI components: 3:1 minimum
<!-- ❌ Insufficient contrast -->
<p style="color: #999; background: #fff;">Low contrast text</p>
<!-- Contrast ratio: 2.85:1 (FAIL) -->
<!-- ✅ Sufficient contrast -->
<p style="color: #333; background: #fff;">High contrast text</p>
<!-- Contrast ratio: 12.63:1 (PASS) -->6. Language Declaration
The Problem: Screen readers don't know what language to use.
<!-- ❌ No language specified -->
<!DOCTYPE html>
<html>
<body>
<p>Invoice for services rendered...</p>
</body>
</html>The Solution: Declare document and section languages.
<!-- ✅ Language declared -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Invoice #12345</title>
</head>
<body>
<p>Invoice for services rendered...</p>
<!-- Multi-language content -->
<p lang="es">Factura para servicios prestados...</p>
<p lang="fr">Facture pour services rendus...</p>
</body>
</html>7. Logical Reading Order
The Problem: Visual layout doesn't match reading order.
<!-- ❌ Visual order ≠ DOM order -->
<div style="display: grid;">
<div style="grid-column: 2;">Second visually, first in DOM</div>
<div style="grid-column: 1;">First visually, second in DOM</div>
</div>
<!-- Screen reader reads in wrong order -->The Solution: DOM order matches visual/logical order.
<!-- ✅ Correct reading order -->
<div style="display: grid;">
<div style="grid-column: 1; order: 1;">First</div>
<div style="grid-column: 2; order: 2;">Second</div>
</div>8. Form Accessibility
The Problem: Forms without labels are unusable.
<!-- ❌ No labels -->
<input type="text" placeholder="Name">
<input type="email" placeholder="Email">The Solution: Proper labels and instructions.
<!-- ✅ Accessible form -->
<form>
<div>
<label for="customer-name">Customer Name *</label>
<input
type="text"
id="customer-name"
name="name"
required
aria-required="true"
aria-describedby="name-help"
>
<span id="name-help" class="help-text">Enter full legal name</span>
</div>
<div>
<label for="customer-email">Email Address *</label>
<input
type="email"
id="customer-email"
name="email"
required
aria-required="true"
aria-invalid="false"
>
</div>
<fieldset>
<legend>Payment Method</legend>
<label>
<input type="radio" name="payment" value="card" checked>
Credit Card
</label>
<label>
<input type="radio" name="payment" value="bank">
Bank Transfer
</label>
</fieldset>
</form>Creating Tagged PDFs
What Are PDF Tags?
PDF tags provide structure for screen readers - like HTML tags for PDFs.
Generate Tagged PDFs with pdf-lib
import { PDFDocument, PDFName, PDFDict } from 'pdf-lib';
async function createTaggedPDF(html) {
// Generate basic PDF from HTML
const pdfBytes = await generatePDF(html);
// Load and tag PDF
const pdfDoc = await PDFDocument.load(pdfBytes);
// Mark as tagged
const markInfo = pdfDoc.context.obj({
Marked: true
});
pdfDoc.catalog.set(PDFName.of('MarkInfo'), markInfo);
// Set document language
pdfDoc.catalog.set(PDFName.of('Lang'), PDFName.of('en-US'));
// Set title
pdfDoc.setTitle('Invoice #12345');
pdfDoc.setSubject('Invoice Document');
pdfDoc.setAuthor('Acme Corporation');
return await pdfDoc.save();
}Testing PDF Accessibility
Automated Testing
import { PDFDocument } from 'pdf-lib';
import axe from 'axe-core';
async function testPDFAccessibility(pdfBytes) {
const pdfDoc = await PDFDocument.load(pdfBytes);
const issues = [];
// Check if PDF is tagged
const markInfo = pdfDoc.catalog.get(PDFName.of('MarkInfo'));
if (!markInfo || !markInfo.get(PDFName.of('Marked'))) {
issues.push('PDF is not tagged');
}
// Check if language is set
const lang = pdfDoc.catalog.get(PDFName.of('Lang'));
if (!lang) {
issues.push('Document language not specified');
}
// Check if title is set
const title = pdfDoc.getTitle();
if (!title || title.trim() === '') {
issues.push('Document title not set');
}
return {
passes: issues.length === 0,
issues: issues
};
}Manual Testing with Screen Readers
Test with actual screen readers:
-
NVDA (Windows, free)
- Download from nvaccess.org
- Open PDF in Adobe Reader
- Navigate with arrow keys
- Verify headings work (H key)
- Verify tables announce properly
-
JAWS (Windows, paid)
- Industry standard screen reader
- Test in Adobe Acrobat Pro
-
VoiceOver (macOS, built-in)
- Enable: System Preferences → Accessibility
- Open PDF in Preview
- Navigate with VO + arrow keys
-
Narrator (Windows, built-in)
- Enable: Win + Ctrl + Enter
Testing checklist:
- Can navigate by headings
- Tables announce row/column headers
- Images have alt text
- Links are descriptive
- Form fields have labels
- Reading order is logical
- No information lost when using screen reader
Accessibility Checklist
Structure:
- Proper HTML5 semantic elements
- Logical heading hierarchy (h1 → h2 → h3)
- Document language declared
- Reading order is logical
Images:
- All images have alt text
- Decorative images have empty alt (
alt="") - Complex images have detailed descriptions
Tables:
- Tables have
<caption> - Header cells use
<th>withscope - Complex tables use
headersattribute
Color and Contrast:
- 4.5:1 contrast for normal text
- 3:1 contrast for large text
- Information not conveyed by color alone
Forms:
- All inputs have associated
<label> - Required fields indicated (not just color)
- Error messages are descriptive
PDF-Specific:
- PDF is tagged
- Document title is set
- Metadata includes author, subject
- Bookmarks for long documents
Complete Accessible Invoice Example
async function generateAccessibleInvoice(invoiceData) {
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Invoice ${invoiceData.number} - ${invoiceData.company}</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #1a1a1a;
background: #ffffff;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
h1, h2, h3 {
margin-top: 1em;
margin-bottom: 0.5em;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
th, td {
padding: 0.75em;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #f0f0f0;
font-weight: bold;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-weight: 600;
}
.status-paid {
background: #dcfce7;
color: #166534;
}
.status-paid::before {
content: "✓ ";
}
</style>
</head>
<body>
<header role="banner">
<img src="${invoiceData.logo_url}" alt="${invoiceData.company} logo" width="150">
<h1>Invoice ${invoiceData.number}</h1>
</header>
<main role="main">
<section aria-labelledby="invoice-details">
<h2 id="invoice-details">Invoice Details</h2>
<dl>
<dt>Invoice Number:</dt>
<dd>${invoiceData.number}</dd>
<dt>Date:</dt>
<dd><time datetime="${invoiceData.date}">${invoiceData.formatted_date}</time></dd>
<dt>Status:</dt>
<dd><span class="status-badge status-paid">Paid</span></dd>
</dl>
</section>
<section aria-labelledby="customer-info">
<h2 id="customer-info">Customer Information</h2>
<address>
<strong>${invoiceData.customer.name}</strong><br>
${invoiceData.customer.address}<br>
${invoiceData.customer.city}, ${invoiceData.customer.state} ${invoiceData.customer.zip}
</address>
</section>
<section aria-labelledby="line-items">
<h2 id="line-items">Line Items</h2>
<table>
<caption>Detailed breakdown of services and costs</caption>
<thead>
<tr>
<th scope="col">Description</th>
<th scope="col">Quantity</th>
<th scope="col">Unit Price</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
${invoiceData.items.map(item => `
<tr>
<td>${item.description}</td>
<td>${item.quantity}</td>
<td>$${item.price.toFixed(2)}</td>
<td>$${(item.quantity * item.price).toFixed(2)}</td>
</tr>
`).join('')}
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="3">Total Amount Due</th>
<td><strong>$${invoiceData.total.toFixed(2)}</strong></td>
</tr>
</tfoot>
</table>
</section>
</main>
<footer role="contentinfo">
<p>
<strong>${invoiceData.company}</strong><br>
${invoiceData.company_address}<br>
Phone: <a href="tel:${invoiceData.phone}">${invoiceData.phone}</a><br>
Email: <a href="mailto:${invoiceData.email}">${invoiceData.email}</a>
</p>
</footer>
</body>
</html>
`;
// Generate tagged PDF
const pdfBytes = await generatePDF(html);
const taggedPDF = await createTaggedPDF(pdfBytes);
return taggedPDF;
}Resources and Tools
Testing Tools
- PAC 3 (PDF Accessibility Checker) - Free, Windows
- Adobe Acrobat Pro - Built-in accessibility checker
- WAVE - Web accessibility evaluation tool
- axe DevTools - Browser extension
Screen Readers
- NVDA - nvaccess.org (Free, Windows)
- JAWS - freedomscientific.com (Paid, Windows)
- VoiceOver - Built into macOS/iOS
- TalkBack - Built into Android
Standards
- WCAG 2.1 - w3.org/WAI/WCAG21/quickref/
- PDF/UA - ISO 14289 (PDF Universal Accessibility)
- Section 508 - section508.gov
Final Thoughts
Accessibility isn't a feature. It's a requirement.
Every PDF you generate should be accessible from day one. It's not harder - it just requires thinking about structure, semantics, and users.
Build accessibility in, and you'll:
- ✅ Avoid lawsuits
- ✅ Reach more users
- ✅ Improve SEO
- ✅ Build better products
Everyone wins when documents are accessible.
Ready to generate accessible PDFs? Try reportgen.io - built with WCAG compliance in mind.