Accessible Maps Explained: Contrast, Text and Alternatives
Problem statement
A map is an image, and an image is the least accessible way to present information. Four groups of readers are routinely excluded, and none of them can tell you so from the page:
- Colour-blind readers โ roughly one man in twelve. Measured, the minimum perceptual distance between the ten
tab10colours falls from ฮE 27.7 in normal vision to 7.2 under deuteranopia and 4.6 under protanopia. - Readers with low vision โ a figure scaled into a 90 mm column shrinks by 0.443, turning 8 pt type into 3.54 pt, below any accessibility guideline.
- Screen-reader users โ a map with no alternative text is, to them, nothing at all.
- Readers of a printed greyscale copy โ a hue-encoded map collapses entirely.
All four are testable before publication, and three of the four are fixed by the same decisions that make a map clearer for everybody.
Quick answer
Four checks, each with a number attached:
def accessibility_report(fig, palette, target_width_mm=None):
return {
# 1. contrast of every text and line colour against its background
"contrast": [(name, round(contrast_ratio(c, (1, 1, 1)), 2))
for name, c in palette.items()],
# 2. minimum type size after the figure is placed
"min_effective_pt": min_text_size(fig, target_width_mm),
# 3. palette separation under the three deficiencies
"cvd_min_dE": worst_cvd_distance(list(palette.values())),
# 4. does anything rely on colour alone?
"colour_only_encodings": find_colour_only(fig),
}
Thresholds worth holding to: 4.5:1 contrast for text, 3:1 for graphical elements, 6 pt minimum effective type size in print, ฮE 10 minimum separation under simulated colour vision deficiency.
Step-by-step solution
1. Measure contrast rather than judging it
The WCAG contrast ratio is computable from any two colours, and the numbers are frequently surprising. Measured against a white background:
colour ratio verdict
#1a3a6b 11.28 passes for text at any size
#475569 7.58 passes for text
#64748b 4.76 passes for text at 4.5:1, just
#94a3b8 2.56 graphical elements only, and marginal
#e2e8f0 1.23 invisible โ a hairline, not a line
Three thresholds matter: 4.5:1 for normal text, 3:1 for large text (18 pt, or 14 pt bold) and for graphical objects such as lines and icons, and 7:1 for the enhanced level.
That means a graticule at #94a3b8 is below the 3:1 threshold and should be treated as decorative rather than informative โ which is usually what it is.
2. Never encode meaning in colour alone
This is the single most impactful rule, and it is straightforward to satisfy:
- Categories โ add hatching, marker shape, or a direct label.
- Ordered data โ use a ramp with monotonic lightness so the order survives greyscale and colour vision deficiency.
- Highlighting โ add an outline or an annotation, not just a different fill.
- Lines โ vary dash pattern as well as colour.
A sequential ramp with monotonic lightness passes almost every accessibility test automatically. Measured, viridis runs from L* 14.9 to 90.9 with no reversals; jet reverses three times, which destroys the ordering for a greyscale or colour-blind reader.
3. Set a type-size floor and enforce it after placement
Point sizes are physical, so the effective size depends on how the figure is scaled. Measured: 8 pt is 2.82 mm and 6 pt is 2.12 mm; an 8-inch figure placed in a 90 mm column scales everything by 0.443.
The fix is not larger type in a big figure โ it is drawing the figure at its final size, so that the numbers in the code are the numbers on the page.
4. Choose the text colour per band, not per map
There is no single label colour that works across a colour ramp. Measured over nine steps: white text clears 4.5:1 on 4 of 9 viridis steps and black on 5 of 9; on YlGnBu it is 3 and 6.
Practical options, in order: put labels outside the polygons, add a halo, or switch the text colour based on the lightness of the fill beneath it.
5. Write alternative text that carries the finding
Alt text for a map is not a description of the image; it is the sentence the map exists to communicate, plus enough structure to be useful:
"Choropleth of unemployment rate by district, England, 2025. Rates range from 2.1% to 9.8%. The highest rates form a band across the north-east coast; the lowest are in the south-west. Source: ONS."
A long description โ a table of the underlying values, or a linked data file โ is the accessible equivalent of letting the reader look closer.
6. Publish the data alongside the map
The most accessible version of a map is the table behind it. Shipping a CSV or a GeoPackage next to the figure serves screen-reader users, readers who want a different classification, and anybody checking your work.
It is also the only route that works for readers with no vision at all, for whom no amount of contrast tuning helps.
Code examples
Example 1 โ a contrast audit of every colour on the map
import numpy as np
import matplotlib.colors as mc
def relative_luminance(rgb):
c = np.asarray(mc.to_rgb(rgb), dtype=float)
lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
return float(0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2])
def contrast_ratio(fg, bg="white"):
a, b = relative_luminance(fg), relative_luminance(bg)
hi, lo = max(a, b), min(a, b)
return (hi + 0.05) / (lo + 0.05)
def contrast_audit(colours: dict, background="white", text_threshold=4.5,
graphic_threshold=3.0):
print(f"{'element':18} {'colour':9} {'ratio':>6} verdict")
failures = []
for name, colour in colours.items():
ratio = contrast_ratio(colour, background)
if ratio >= 7:
verdict = "text, enhanced"
elif ratio >= text_threshold:
verdict = "text, minimum"
elif ratio >= graphic_threshold:
verdict = "graphical elements only"
else:
verdict = "decorative only โ carries no meaning"
failures.append(name)
print(f"{name:18} {colour:9} {ratio:6.2f} {verdict}")
return failures
Example 2 โ finding elements that rely on colour alone
import matplotlib.collections as mcoll
def colour_only_check(ax):
"""Do the plotted collections differ in anything except colour?"""
findings = []
collections = [c for c in ax.collections if len(c.get_paths())]
hatches = {getattr(c, "get_hatch", lambda: None)() for c in collections}
widths = {round(float(np.atleast_1d(c.get_linewidth())[0]), 2)
for c in collections if len(np.atleast_1d(c.get_linewidth()))}
styles = {str(np.atleast_1d(c.get_linestyle())[0]) for c in collections
if len(np.atleast_1d(c.get_linestyle()))}
if len(collections) > 1:
if hatches == {None}:
findings.append("no hatching โ categories differ by colour alone")
if len(widths) == 1 and len(styles) == 1:
findings.append("uniform line width and style across layers")
if not any(t.get_text().strip() for t in ax.texts):
findings.append("no direct labels โ nothing survives if colour is lost")
for f in findings:
print(f" ! {f}")
return findings
Example 3 โ alt text generated from the data
def map_alt_text(gdf, column, *, what, where, when, source, top_n=3):
"""A sentence carrying the finding, not a description of the picture."""
values = gdf[column].dropna()
highest = gdf.nlargest(top_n, column)
lowest = gdf.nsmallest(top_n, column)
name_col = next((c for c in ("name", "NAME", "area", "district")
if c in gdf.columns), gdf.columns[0])
return (
f"Choropleth map of {what} by area, {where}, {when}. "
f"Values range from {values.min():,.1f} to {values.max():,.1f} "
f"(median {values.median():,.1f}) across {len(values):,} areas. "
f"Highest: {', '.join(highest[name_col].astype(str))}. "
f"Lowest: {', '.join(lowest[name_col].astype(str))}. "
f"Source: {source}."
)
>>> map_alt_text(districts, "unemployment_rate", what="unemployment rate",
... where="England", when="2025", source="ONS")
'Choropleth map of unemployment rate by area, England, 2025. Values range from
2.1 to 9.8 (median 4.6) across 309 areas. Highest: Hartlepool, Blackpool,
Middlesbrough. Lowest: Rushcliffe, Hart, Wokingham. Source: ONS.'
Generating alt text from the data rather than writing it by hand means it stays correct when the data updates, and it makes the omission visible when a figure has none.
Explanation
Why contrast ratios have three thresholds
The WCAG thresholds come from readability research at different sizes and roles. Small text needs 4.5:1 because thin strokes lose contrast at small sizes; large text and graphical objects can work at 3:1 because their strokes are thicker; 7:1 is the enhanced level for readers with lower acuity.
Map elements sit in the graphical category, which is why a coastline at 4.76:1 is comfortable and a graticule at 2.56:1 is not carrying information โ it is texture.
Why lightness solves several problems at once
A ramp with monotonic lightness is readable in greyscale, under all three colour vision deficiencies, at small sizes, and on a bad projector. That is four accessibility problems addressed by one property.
It is also why the accessibility advice and the cartographic advice converge: viridis was designed for perceptual uniformity and turns out to be an accessibility choice as well. The reverse holds too โ jet, with three lightness reversals, fails both tests.
Why alt text should carry the finding rather than describe the image
"A map of England with coloured districts" tells a screen-reader user nothing they could not infer from the caption. The information they are missing is what the map shows: the range, the pattern, the extremes.
That is also a useful discipline for sighted readers. If the finding cannot be stated in two sentences, the map may not have one.
Why publishing the data is the strongest accessibility measure
Every technique above improves a visual representation. None of them gives a reader without vision access to the numbers.
A CSV or a GeoPackage alongside the figure does โ and it simultaneously serves anybody who wants to re-classify, re-project, check, or reuse the work. It is the cheapest thing on this list and the one most often omitted.
Edge cases or notes
- Test contrast against the actual background, including any basemap and any alpha.
- Alpha blending changes the effective colour. Composite before measuring.
- Hatching in matplotlib scales oddly at export; check the density in the exported file.
- Interactive maps need keyboard access, focus indicators and a text alternative, not just contrast.
- Do not rely on red and green for good and bad โ it is the most common deficiency and the semantics make the confusion worse.
- Captions are not alt text. Both are needed, and they say different things.
- Minimum print type is about 6 pt โ 2.12 mm โ and many guidelines set 8 pt.
- A greyscale print test catches most of this in one step.
Internal links
- How to check a map for colour-blind readers โ the simulation and the thresholds
- Colour on maps explained: sequential, diverging and qualitative โ why monotonic lightness matters
- Visual hierarchy explained: what a map reader sees first โ the contrast ratios in use
- Fixing map text that is too small in the exported file โ the type-size failure
- How to choose and test a colour ramp in Python โ the contrast test in the ramp suite
- How to build a print-ready map layout in Matplotlib โ drawing at final size
- How to add map legends and labels โ direct labelling
- How to package GIS deliverables โ shipping the data with the figure
FAQ
What contrast ratio does a map need?
4.5:1 for normal text, 3:1 for large text and graphical elements such as lines and symbols, 7:1 for the enhanced level. Measured, #64748b on white is 4.76:1 and #94a3b8 is 2.56:1.
How do I make a map accessible to colour-blind readers?
Do not encode meaning in colour alone. Use a ramp with monotonic lightness for ordered data, and add hatching, shape or direct labels for categories.
What is the minimum text size on a printed map?
About 6 pt โ 2.12 mm โ and many guidelines require 8 pt. Check the size after the figure is placed: an 8-inch figure in a 90 mm column scales by 0.443.
What should a map's alt text say?
The finding, not the picture: what is mapped, the range of values, where the extremes are, and the source. Generate it from the data so it stays correct.
Can I put labels on top of a choropleth accessibly?
Not with one text colour. White clears 4.5:1 on only 4 of viridis's 9 steps and black on 5, so use a halo, switch per band, or label outside the polygon.
What is the single most effective accessibility measure?
Publishing the underlying data alongside the map. It is the only route that works for a reader with no vision, and it serves everybody else too.