Accessibility compliance
Practical accessibility patterns for journalism and academic web publishing.
When to activate
- Building or auditing news websites
- Writing alt text for article images
- Creating accessible data visualizations
- Developing tools that journalists use
- Ensuring multimedia content is accessible
- Meeting legal accessibility requirements
- Publishing academic content online
WCAG essentials for news sites
WCAG 2.2 became a W3C Recommendation in October 2023 and is backwards-compatible with 2.1. Targeting 2.2 AA is the right default for new work; 2.1 AA remains the floor in most legal regimes (see "Legal requirements" below).
WCAG 2.2 added nine criteria over 2.1. Most relevant for news sites: 2.5.8 Target Size (Minimum) AA — interactive targets at least 24×24 CSS pixels; 2.4.11 Focus Not Obscured (Minimum) AA — focused element not fully hidden by sticky headers / chat widgets; 3.3.8 Accessible Authentication AA — no cognitive function tests (e.g., transcribing distorted text) without an alternative; 3.3.7 Redundant Entry A — don't ask users to re-enter the same data within a session.
The four principles (POUR)
## WCAG 2.2 AA checklist (journalism focus)
### Perceivable
- [ ] Images have meaningful alt text
- [ ] Videos have captions
- [ ] Audio has transcripts
- [ ] Color isn't the only way to convey info
- [ ] Text can be resized to 200% without breaking
### Operable
- [ ] All functions work with keyboard only
- [ ] No keyboard traps
- [ ] Skip links to main content
- [ ] Page titles describe content
- [ ] Focus visible on all interactive elements
### Understandable
- [ ] Language is declared in HTML
- [ ] Navigation is consistent
- [ ] Error messages are clear
- [ ] Labels describe form fields
### Robust
- [ ] Valid HTML
- [ ] ARIA used correctly (or not at all)
- [ ] Works with screen readers
- [ ] Doesn't break with zoom/text resize
Image accessibility
Alt text for journalism
## Alt text decision tree
### News photos
- **WHO** is in the image (if identifiable and relevant)
- **WHAT** is happening (the action or situation)
- **WHERE** (if location matters to story)
- **Don't**: Repeat caption text verbatim
### Examples
PHOTO: Protesters holding signs outside courthouse
BAD: "Protesters"
BAD: "Image of protest" (redundant "image of")
GOOD: "Approximately 50 protesters hold signs reading 'Justice Now' outside the federal courthouse in downtown Seattle"
PHOTO: Headshot of interview subject
BAD: "Photo"
GOOD: "Dr. Sarah Chen, epidemiologist at Johns Hopkins"
PHOTO: Chart embedded as image
BAD: "Chart showing data"
GOOD: "Bar chart showing unemployment rising from 3.5% to 8.2% between March and June 2020. Full data in table below."
Alt text for AI-generated images
Newsroom transparency policies generally require disclosing that an image is AI-generated; that disclosure belongs in the caption AND the alt text, because screen-reader users see only the alt text. Describe the visual content first, then note the provenance. Don't lead with "AI-generated image of..." — describe the subject the way you would for any photo, then add the AI source.
GOOD: "Illustration of a flooded downtown street with abandoned cars, water reaching first-floor windows. AI-generated by [tool] for editorial use."
GOOD (decorative AI illustration): "Decorative AI-generated abstract pattern in blue and orange." — but consider whether the image should have empty alt (alt="") since it carries no information.
For AI-generated images of real people or events, AP and most newsroom guidelines treat the image as a manipulation, not a photograph — disclosure in alt text is required, not optional.
Alt text Python helper
def generate_alt_text_prompt(context: dict) -> str:
"""Generate prompt for AI alt text assistance."""
return f"""
Write alt text for a news image.
Story context: {context.get('headline', 'Unknown')}
Image type: {context.get('image_type', 'photo')}
Caption (if any): {context.get('caption', 'None')}
Guidelines:
- Be concise (under 125 characters if possible)
- Don't start with "Image of" or "Photo of"
- Include relevant details for story context
- Don't duplicate caption exactly
- Describe what's visually important
If this is decorative only, respond: ""
"""
def is_decorative(image_context: str) -> bool:
"""Check if image is purely decorative (empty alt appropriate)."""
decorative_indicators = [
'decorative',
'separator',
'background',
'spacer',
'border'
]
return any(ind in image_context.lower() for ind in decorative_indicators)
Accessible data visualization
Chart accessibility checklist
## Making charts accessible
### Essential elements
- [ ] Text alternative describing the key insight
- [ ] Data table available (visible or linked)
- [ ] Colors have sufficient contrast
- [ ] Patterns/textures supplement color coding
- [ ] Labels directly on chart (not legend-only)
- [ ] Title describes what chart shows
### Interactive charts
- [ ] Keyboard navigable
- [ ] Focus indicators visible
- [ ] Screen reader announces data points
- [ ] Tooltips accessible via keyboard
- [ ] Zooming doesn't break layout
Accessible chart component
<!-- Accessible chart pattern -->
<figure role="figure" aria-labelledby="chart-title" aria-describedby="chart-desc">
<figcaption>
<h3 id="chart-title">Unemployment Rate 2020-2024</h3>
<p id="chart-desc">
Line chart showing unemployment starting at 3.5% in January 2020,
spiking to 14.7% in April 2020, and gradually declining to 3.9% by 2024.
</p>
</figcaption>
<!-- The chart itself -->
<div id="chart" role="img" aria-label="Interactive line chart. Data table available below.">
<!-- Chart renders here -->
</div>
<!-- Always provide data table -->
<details>
<summary>View data table</summary>
<table>
<caption>Monthly unemployment rate data</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Unemployment Rate (%)</th>
</tr>
</thead>
<tbody>
<tr><td>Jan 2020</td><td>3.5</td></tr>
<tr><td>Apr 2020</td><td>14.7</td></tr>
<!-- etc -->
</tbody>
</table>
</details>
</figure>
Color-blind safe palettes
# Safe color palettes for data visualization
COLOR_PALETTES = {
# Paul Tol's color schemes - widely tested for accessibility
'bright': [
'#4477AA', # Blue
'#EE6677', # Red
'#228833', # Green
'#CCBB44', # Yellow
'#66CCEE', # Cyan
'#AA3377', # Purple
'#BBBBBB', # Grey
],
# Categorical (safe for most color blindness)
'categorical': [
'#332288', # Indigo
'#88CCEE', # Cyan
'#44AA99', # Teal
'#117733', # Green
'#999933', # Olive
'#DDCC77', # Sand
'#CC6677', # Rose
'#882255', # Wine
],
# Sequential (single hue)
'sequential_blue': [
'#f7fbff',
'#deebf7',
'#c6dbef',
'#9ecae1',
'#6baed6',
'#4292c6',
'#2171b5',
'#084594',
],
# Diverging (for data with meaningful midpoint)
'diverging': [
'#d73027', # Red (negative)
'#f46d43',
'#fdae61',
'#fee08b',
'#ffffbf', # Neutral
'#d9ef8b',
'#a6d96a',
'#66bd63',
'#1a9850', # Green (positive)
]
}
def validate_contrast(color1: str, color2: str) -> float:
"""Calculate WCAG contrast ratio between two colors."""
def hex_to_rgb(hex_color):
hex_color = hex_color.lstrip('#')
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def relative_luminance(rgb):
r, g, b = [x / 255.0 for x in rgb]
r = r / 12.92 if r <