Encoding Data with Shape and Texture, Not Just Color
Two series that look clearly different to you collapse into one muddy tone for a user with red-green color-vision deficiency, and your chart becomes unreadable for roughly one in twelve men.
The fix is redundant encoding: carry the same distinction in a second, non-color channel — marker shape, line dash pattern, or a texture/hatch fill — so meaning survives when hue does not. This page is part of color and contrast encoding for charts, and it is the concrete implementation of WCAG 1.4.1 Use of Color: never let color be the only thing that tells two things apart. It pairs naturally with a colorblind-safe palette, which reduces how often the fallback is needed but never removes the need for it.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: series differ by color only; identical in grayscale / under CVD.
function styleSeries(i: number): { stroke: string } {
const colors = ["#dc2626", "#16a34a", "#1d4ed8"]; // red/green pair collapses for deutan
// A11Y: nothing but hue distinguishes these lines — WCAG 1.4.1 failure.
return { stroke: colors[i % colors.length] };
}
// ✅ FIXED: hue PLUS a redundant channel, reflected in the accessible name.
type Shape = "circle" | "square" | "triangle";
interface SeriesEncoding {
stroke: string; // colorblind-safe hue (still useful for most users)
dash: string; // SVG stroke-dasharray: "" solid, "10 6" dashed, "2 5" dotted
shape: Shape; // marker shape for points
patternId: string; // id of an SVG <pattern> for filled areas
channelName: string; // human name of the non-color channel, for aria-label
}
const OKABE_ITO = ["#0072b2", "#d55e00", "#009e73", "#cc79a7", "#e69f00"] as const;
const DASHES = ["", "10 6", "2 5", "12 4 2 4"] as const;
const SHAPES: Shape[] = ["circle", "square", "triangle"];
const CHANNEL_NAMES = ["solid circle", "dashed square", "dotted triangle", "dash-dot"] as const;
// PERF: compute the full encoding once per series at setup, not per mark or per frame.
function encodeSeries(i: number): SeriesEncoding {
return {
stroke: OKABE_ITO[i % OKABE_ITO.length],
dash: DASHES[i % DASHES.length],
shape: SHAPES[i % SHAPES.length],
patternId: `hatch-${i % 4}`,
// A11Y: the non-color channel is announced too, so AT users get the same
// distinction sighted users get from the marker ("Revenue, dashed square").
channelName: CHANNEL_NAMES[i % CHANNEL_NAMES.length],
};
}
The point the fixed version makes is that color is not removed — a well-chosen hue is still the fastest channel for the majority of users — it is backed up. Every series now differs in at least two channels, so losing either one (to color-vision deficiency, grayscale printing, or a low-contrast projector) still leaves a working distinction. Crucially the redundant channel is also folded into the accessible name, so a screen-reader user hears “dashed square”, giving them the same locatable handle a sighted user gets from the marker.
Rendering the texture fills
SVG <pattern> elements are the cleanest way to add texture to filled marks. Define each pattern once in <defs> and reference it by url(#id), rather than rebuilding pattern geometry per bar.
// Build reusable hatch/dot patterns once; reference them from many marks.
// A11Y: patterns are a REDUNDANT channel — keep a visible stroke so the shape
// is still perceivable even where the fine texture cannot resolve.
function definePatterns(defs: SVGDefsElement): void {
const specs: Array<{ id: string; svg: string }> = [
{ id: "hatch-0", svg: `<line x1="0" y1="0" x2="0" y2="8" stroke="#0072b2" stroke-width="3"/>` },
{ id: "hatch-1", svg: `<circle cx="4" cy="4" r="1.6" fill="#d55e00"/>` },
{ id: "hatch-2", svg: `<line x1="0" y1="4" x2="8" y2="4" stroke="#009e73" stroke-width="2"/>` },
{ id: "hatch-3", svg: `<path d="M0 8 L8 0" stroke="#cc79a7" stroke-width="2"/>` },
];
for (const s of specs) {
const rotate = s.id === "hatch-0" ? ' patternTransform="rotate(45)"' : "";
defs.insertAdjacentHTML(
"beforeend",
`<pattern id="${s.id}" width="8" height="8" patternUnits="userSpaceOnUse"${rotate}>` +
`<rect width="8" height="8" fill="#f8fafc"/>${s.svg}</pattern>`,
);
}
}
Step-by-step fix
// Apply an encoding to an SVG mark and its accessible name.
function applyEncoding(mark: SVGElement, enc: SeriesEncoding, seriesName: string, value: string): void {
if (mark.tagName === "path" || mark.tagName === "polyline") {
mark.setAttribute("stroke", enc.stroke);
mark.setAttribute("stroke-dasharray", enc.dash); // redundant with hue
} else {
mark.setAttribute("fill", `url(#${enc.patternId})`);
}
// A11Y: name carries the non-color channel so it is not color-dependent.
mark.setAttribute("role", "img");
mark.setAttribute("aria-label", `${seriesName}, ${enc.channelName}, ${value}`);
}
Verification
// Assert each series differs from every other in at least one NON-color channel.
function distinctBeyondColor(a: SeriesEncoding, b: SeriesEncoding): boolean {
return a.dash !== b.dash || a.shape !== b.shape || a.patternId !== b.patternId;
}
const encs = [0, 1, 2].map(encodeSeries);
for (let i = 0; i < encs.length; i++)
for (let j = i + 1; j < encs.length; j++)
console.assert(distinctBeyondColor(encs[i], encs[j]), `series ${i} and ${j} differ only by color`);
In DevTools, apply the Rendering tab’s Emulate vision deficiencies → Achromatopsia (full grayscale) and then Deuteranopia, and confirm every pair of series stays visually distinct. Because the distinction now lives in shape, dash, and texture, the chart should read identically whether or not hue is available — that is the whole point of the redundancy.
Why redundancy beats “just pick better colors”
A colorblind-safe palette and redundant non-color encoding solve overlapping but different problems, and you want both. A safe palette like Okabe–Ito maximises how distinguishable hues are under common deficiencies, which reduces failures — but it does not eliminate them. Some users have monochromacy (no usable hue at all); charts get printed or photocopied in grayscale; sunlight and cheap projectors crush color; and once you exceed a handful of series, even a perfect palette runs out of reliably distinct hues. Redundant encoding is what carries the distinction through all of those cases, because shape, dash, and texture do not depend on the color channel at all. The mental model is defence in depth: the safe palette handles the common case elegantly, and the second channel is the fallback that never relies on the thing that might be broken. This is also why the redundant channel belongs in the accessible name — it gives a screen-reader user a concrete, non-visual handle (“dashed square”) that is more locatable than a color word they cannot perceive either.
Edge cases & gotchas
- Texture on tiny marks. A hatch pattern needs area to read; on a 4px scatter point it becomes noise. Use shape for small point marks and reserve texture for large filled regions like bars and areas.
- Dash patterns hiding data. A sparse dotted line can literally skip over a narrow peak, so the gap lands exactly on the feature. Keep dash gaps short relative to the data resolution, and never let the pattern obscure a real value.
- Pattern fills and contrast. A texture is still a data mark and must meet 3:1 non-text contrast against the background where its ink lands. A pale hatch on white fails just as a pale line does; keep the pattern’s strokes dark enough.
- Too many shapes. Beyond about five marker shapes, the extra shapes stop being quickly distinguishable. Past that, switch to small multiples (one chart per series) rather than piling on channels.
- Legend mismatch. If the legend shows colored swatches but the marks show shapes and dashes, a colorblind user cannot map legend to data. The legend must reproduce the exact non-color encoding of the marks.
- Canvas patterns. On Canvas,
createPatternfrom a small offscreen tile is the equivalent of an SVG<pattern>; build the tile once and reuse theCanvasPatternrather than regenerating it per draw, to avoid GC churn.
Frequently Asked Questions
Isn’t a colorblind-safe palette enough on its own?
It helps a lot but is not sufficient. A safe palette maximises distinguishability under common deficiencies, yet monochromacy, grayscale printing, glare, and simply having many series all defeat hue alone. Redundant encoding with shape, dash, or texture carries the distinction through every one of those cases because it does not use the color channel, which is why WCAG 1.4.1 asks for a non-color cue rather than merely a better color.
Which redundant channel should I use for each chart type?
Match the channel to the mark. Use marker shape for scatter and point series, dash pattern for line series (it also disambiguates where lines cross), and texture or hatch fills for bars and filled areas. Small marks cannot hold a texture, so prefer shape there; large filled regions cannot show a marker shape, so use texture. Choosing the channel that suits the mark’s size and geometry keeps the encoding legible.
Do I need to reflect shape and texture in screen-reader output too?
Yes, if you want parity. A screen-reader user gets no benefit from a visual marker, so fold the channel’s name into the mark’s accessible label — “Revenue, dashed square, 4.2M”. This gives them a concrete, locatable handle that does not depend on perceiving color, and it keeps the non-visual description in step with what sighted users see.
Related
- Color and contrast encoding for charts — the guide covering contrast ratios and non-color encoding together.
- Colorblind-safe palettes for categorical data — the palette that reduces how often the fallback is needed.
- Screen-reader-friendly charts — carrying the non-color channel into the accessibility tree.
- SVG vs Canvas architecture — how pattern fills differ between the two engines.