For developers

Accessible interactive pages

To a screen reader, a canvas or WebGL view is only a picture, and with a keyboard it is hard work. So build the rest of the page around the view: give every action a keyboard route, announce feedback, add a text version of what the picture shows, choose colours that colour-blind students can tell apart, and let motion be turned off.

Most of the libraries on this site draw their main view on a canvas or with WebGL, which assistive technology treats as one picture. You cannot change that from outside the library, but you can build the page around the view so that every task has a second route. This page shows how, with an example you can copy, and says what each library offers. The target is WCAG 2.2 level AA [1]; the educators’ page explains what that means in class.

An evaluation of 3,112 life-science data portals found severe accessibility problems on 74.8% of them, and in a preliminary test of three portals, a blind user completed 53.3% of common tasks. Its authors found that following accessibility standards does not by itself make a resource usable [2], so test with people as well as with checklists. Smits and colleagues’ ten rules [3] are a good companion to this page.

Give every pointer action a keyboard route

WCAG asks that everything works from a keyboard, that keyboard focus never gets stuck, that anything done by dragging can also be done with a single pointer without dragging, and that pointer targets are at least 24 by 24 CSS pixels or spaced apart [1]. Interactive components should respond to Tab and the arrow keys as well as to the mouse [3]. In practice:

  • Use real HTML controls. <button type="button"> for actions, and <input> and <select> with a <label> for choices. They work with the keyboard and with screen readers without extra code.
  • Give each thing in the picture a button. One button per variant, as in the example below, or per molecule in a network: this is how a Cytoscape.js pathway diagram became usable from the keyboard in our tests.
  • Replace dragging with buttons and search. A search box, zoom buttons and buttons that jump to the places students need, as in the genome browser examples.
  • Leave the canvas out of the tab order unless your code handles keys on it. If it takes over the arrow keys, Tab must still move on.
  • Make targets big enough. The example’s buttons are 32 pixels tall.

Make focus visible and keep it in order

Keyboard users need to see where they are, and toolbars or overlays must not hide the control that has focus [1].

/* A clear outline for keyboard users only; never remove outlines without a replacement */
:focus-visible { outline: 3px solid #2b56c9; outline-offset: 2px; }

Keep controls in the same order in the HTML as on screen, so that Tab moves through them in reading order. Think about where focus goes when something loads: on this site, the Load the example button moves focus to the example’s frame as it starts to load. If a table or panel scrolls sideways on a phone, give its box tabindex="0", a role and a label, so that keyboard users can scroll it too; the example’s table does this.

Announce feedback with a live region

When a page reacts to what a student does, a screen reader user needs to hear the result without moving focus. WCAG calls these status messages [1]. Put them in an element that is in the page, empty, from the start:

<p id="feedback" role="status" aria-live="polite"></p>

Then change its text with feedback.textContent = "…". Screen readers announce changes to a live region that already exists, and MDN advises starting with an empty one and letting it be exposed to assistive technology before the first update (MDN). Use polite, which waits until the screen reader is idle; assertive interrupts, so keep it for urgent messages. Write one short message per action, and do not update the region on every frame of an animation.

Give canvas and WebGL views a text version

A canvas is one image to assistive technology. Give it role="img" and an aria-label that says in a sentence or two what the picture shows, and put the detail in HTML beside it:

  • A data table of what the picture shows, such as the features in view, the edges of a network or the residues around a site, as WCAG’s rule on information and relationships asks [1]. Keep it small or let students filter it: a table of 100 rows and 100 columns takes 10,000 keystrokes to read [3].
  • Alt text that states the point of an image, briefly, with captions and labels that explain how to read it [3]. Tools are appearing that write descriptions automatically: AltGosling does this for genomics views made with the Gosling toolkit [4].
  • A static image and a sentence when WebGL is missing. Without WebGL, Mol* shows its own message, while NGL and Phylocanvas.gl leave a blank box, so check for WebGL before you start a 3D viewer.

One exception: for Mol*, put role="region" and a label on the viewer’s container, not role="img", because role="img" hides the viewer’s own buttons from assistive technology.

Build a canvas view with three routes to the same information

This example draws seven ClinVar variants in HBB on a canvas, with no library. Students can choose a variant with a button or by clicking the picture, hear a message, and read the same records in a table.

The variants are real ClinVar records from this site’s HBB teaching data. Try it with the keyboard alone, and again with reduced motion switched on in your device’s settings.

Code accessible-variant-view.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>An accessible canvas view: variants in HBB with buttons, spoken feedback and a table</title>
  <style>
    body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
    h1 { font-size: 1.05rem; margin: 0 0 6px; }
    p { margin: 0 0 8px; }
    figure { margin: 0 0 10px; }
    canvas { display: block; width: 100%; height: 170px; border: 1px solid #c3cbd8; border-radius: 6px; cursor: pointer; }
    .key { display: block; margin-top: 2px; }
    figcaption { font-size: 13px; color: #3b4659; margin-top: 4px; }
    .shape { display: inline-block; width: 1em; text-align: center; }
    .pathogenic { color: #eb6834; }
    .benign { color: #2a78d6; }
    .group-label { font-weight: 600; font-size: 14px; margin: 0 0 4px; }
    .choices { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; margin: 0 0 8px; padding: 0; }
    .choices button { font: inherit; font-size: 14px; min-height: 32px; padding: 3px 10px; border: 1px solid #5c677a; border-radius: 6px; background: #fff; color: #172033; cursor: pointer; }
    .choices button[aria-pressed="true"] { background: #172033; border-color: #172033; color: #fff; }
    .choices button[aria-pressed="true"] .shape { color: inherit; }
    button:focus-visible { outline: 3px solid #2b56c9; outline-offset: 2px; }
    #feedback { min-height: 3em; margin: 0 0 6px; padding: 6px 10px; border-left: 4px solid #2b56c9; background: #f4f6fa; }
    #feedback:empty { min-height: 0; padding: 0; border: 0; }   /* still in the page, so it is announced once filled */
    #motion { font-size: 13px; color: #3b4659; }
    .table-title { font-weight: 600; font-size: 14px; margin: 4px 0; }
    .table-box { overflow-x: auto; }
    .table-box:focus-visible { outline: 3px solid #2b56c9; outline-offset: 2px; }
    table { border-collapse: collapse; width: 100%; font-size: 13px; }
    th, td { text-align: left; vertical-align: top; padding: 3px 8px; border-bottom: 1px solid #dde2ea; white-space: nowrap; }
    td:nth-child(5) { white-space: normal; min-width: 9em; }
    tr.selected td { background: #fff1e6; font-weight: 600; }
    .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
  </style>
</head>
<body>
  <h1>Seven ClinVar variants in the HBB gene</h1>
  <p>Choose a variant with a button, or click it in the picture. The picture, the message below the buttons and the table give the same information.</p>

  <figure>
    <!-- A canvas is a single image to a screen reader, so it gets a short summary here.
         The details live in real HTML below: buttons, a live message and a table. -->
    <canvas id="view" role="img" aria-describedby="view-caption"
            aria-label="Diagram of the HBB gene with its three exons and seven ClinVar variants, five pathogenic and two benign. The buttons and the table below give the same information."></canvas>
    <figcaption id="view-caption">HBB on GRCh38, chr11:5,225,464-5,227,071. The gene is read from right to left (reverse strand). Tall boxes are coding sequence.
      <span class="key"><span class="shape pathogenic" aria-hidden="true">▲</span> pathogenic or likely pathogenic
      <span class="shape benign" aria-hidden="true">●</span> benign</span></figcaption>
  </figure>

  <p class="group-label" id="choices-label">Variants, in order along the chromosome</p>
  <ul class="choices" id="choices" aria-labelledby="choices-label"></ul>

  <!-- The live region is in the page from the start, and empty: screen readers announce later changes to it -->
  <p id="feedback" role="status" aria-live="polite"></p>
  <p id="motion"></p>

  <p class="table-title" id="table-title">The same variants as a table (ClinVar release of 4 October 2026)</p>
  <!-- On a phone the table scrolls sideways: tabindex lets keyboard users scroll it too -->
  <div class="table-box" role="region" aria-labelledby="table-title" tabindex="0">
    <table aria-labelledby="table-title">
      <thead>
        <tr><th scope="col">Variant</th><th scope="col">GRCh38 position and change</th><th scope="col">In HBB</th><th scope="col">Protein</th><th scope="col">Known as</th><th scope="col">ClinVar</th></tr>
      </thead>
      <tbody id="rows"></tbody>
    </table>
  </div>

  <script>
    // Real ClinVar records, as in this site's HBB teaching data (GRCh38; change given on the forward strand)
    const VARIANTS = [
      { id: "rs1609812",  pos: 5225911, change: "G>A", c: "c.316-185C>T", p: "p.?",        where: "intron 2", name: "IVS-II-666 C>T",            short: "IVS-II-666", clinvar: "Benign" },
      { id: "rs11549407", pos: 5226774, change: "G>A", c: "c.118C>T",     p: "p.Gln40Ter", where: "exon 2",   name: "codon 39 nonsense, β0-thalassaemia", short: "Codon 39", clinvar: "Pathogenic" },
      { id: "rs35004220", pos: 5226820, change: "C>T", c: "c.93-21G>A",   p: "p.?",        where: "intron 1", name: "IVS-I-110, β+-thalassaemia", short: "IVS-I-110",  clinvar: "Pathogenic" },
      { id: "rs33950507", pos: 5226943, change: "C>T", c: "c.79G>A",      p: "p.Glu27Lys", where: "exon 1",   name: "HbE",                         short: "HbE",        clinvar: "Pathogenic" },
      { id: "rs334",      pos: 5227002, change: "T>A", c: "c.20A>T",      p: "p.Glu7Val",  where: "exon 1",   name: "HbS, the sickle-cell variant", short: "HbS",       clinvar: "Pathogenic" },
      { id: "rs33930165", pos: 5227003, change: "C>T", c: "c.19G>A",      p: "p.Glu7Lys",  where: "exon 1",   name: "HbC",                         short: "HbC",        clinvar: "Pathogenic/Likely pathogenic" },
      { id: "rs713040",   pos: 5227013, change: "A>G", c: "c.9T>C",       p: "p.His3=",    where: "exon 1",   name: "synonymous change",           short: "His3His",    clinvar: "Benign" }
    ];
    // RefSeq NM_000518.5 on GRCh38, numbered in the gene's direction
    const GENE = { start: 5225464, end: 5227071 };
    const EXONS = [
      { n: 3, start: 5225464, end: 5225726, cdsStart: 5225598, cdsEnd: 5225726 },
      { n: 2, start: 5226577, end: 5226799, cdsStart: 5226577, cdsEnd: 5226799 },
      { n: 1, start: 5226930, end: 5227071, cdsStart: 5226930, cdsEnd: 5227021 }
    ];
    // Blue and orange stay distinct with the common kinds of colour blindness; shape repeats the meaning
    const COLOUR = { pathogenic: "#eb6834", benign: "#2a78d6", ink: "#172033", muted: "#5c677a", rule: "#8a94a6" };
    const pathogenic = v => /pathogenic/i.test(v.clinvar) && !/benign/i.test(v.clinvar);
    const fmt = n => n.toLocaleString("en-GB");

    const canvas = document.getElementById("view");
    const ctx = canvas.getContext("2d");
    const feedback = document.getElementById("feedback");
    const motionNote = document.getElementById("motion");
    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");

    const WHOLE = { start: GENE.start - 70, end: GENE.end + 70 };
    let view = Object.assign({}, WHOLE);
    let selected = null;
    let frame = 0;

    // Screen position of a base, in the current view or in another stretch of the chromosome
    const toX = (pos, width, area = view) => 14 + (pos - area.start) / (area.end - area.start) * (width - 28);

    // Put markers that would overlap on separate rows, as genome browsers do in "expanded" mode
    function layout(width, area) {
      const rowEnds = [];
      return VARIANTS.map(v => {
        const x = toX(v.pos, width, area);
        let row = rowEnds.findIndex(end => x - end > 16);
        if (row === -1) { row = rowEnds.length; rowEnds.push(x); } else { rowEnds[row] = x; }
        return { v, x, y: 112 + row * 20 };
      });
    }

    // Text drawn over lines gets a white patch behind it, so it stays readable
    function label(text, x, y) {
      const w = ctx.measureText(text).width;
      const left = ctx.textAlign === "center" ? x - w / 2 : x;
      ctx.save();
      ctx.fillStyle = "#fff";
      ctx.fillRect(left - 3, y - 8, w + 6, 16);
      ctx.restore();
      ctx.fillText(text, x, y);
    }

    function draw() {
      const width = canvas.clientWidth;
      // Make room for every row of markers in the most crowded view, the whole gene, so the height never jumps
      const lowest = Math.max(...layout(width, WHOLE).map(m => m.y));
      const height = lowest + 18;
      if (canvas.style.height !== height + "px") canvas.style.height = height + "px";
      const ratio = window.devicePixelRatio || 1;            // draw at the screen's real resolution
      if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
        canvas.width = Math.round(width * ratio);
        canvas.height = Math.round(height * ratio);
      }
      ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
      ctx.clearRect(0, 0, width, height);
      ctx.font = "12px system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
      ctx.textBaseline = "middle";
      ctx.lineWidth = 1;

      // Ruler: about one labelled tick per 110 pixels
      const span = view.end - view.start;
      const step = [10, 20, 50, 100, 200, 500, 1000, 2000].find(s => span / s <= Math.max(2, width / 110));
      ctx.strokeStyle = COLOUR.rule;
      ctx.fillStyle = COLOUR.muted;
      ctx.textAlign = "center";
      ctx.beginPath(); ctx.moveTo(14, 24); ctx.lineTo(width - 14, 24); ctx.stroke();
      for (let t = Math.ceil(view.start / step) * step; t <= view.end; t += step) {
        const x = toX(t, width);
        ctx.beginPath(); ctx.moveTo(x, 20); ctx.lineTo(x, 28); ctx.stroke();
        if (x > 36 && x < width - 36) ctx.fillText(fmt(t), x, 11);
      }

      // The selected variant gets a dashed guide line up to the ruler
      const markers = layout(width);
      const chosen = selected && markers.find(m => m.v.id === selected.id);
      if (chosen) {
        ctx.strokeStyle = COLOUR.ink;
        ctx.setLineDash([4, 3]);
        ctx.beginPath(); ctx.moveTo(chosen.x, 30); ctx.lineTo(chosen.x, chosen.y - 12); ctx.stroke();
        ctx.setLineDash([]);
      }

      // The gene: a line for the introns, short boxes for untranslated sequence, tall boxes for coding sequence
      const y = 64;
      const g1 = Math.max(0, toX(GENE.start - 0.5, width)), g2 = Math.min(width, toX(GENE.end + 0.5, width));
      ctx.strokeStyle = COLOUR.ink;
      ctx.fillStyle = COLOUR.ink;
      if (g2 > g1) {
        ctx.beginPath(); ctx.moveTo(g1, y); ctx.lineTo(g2, y); ctx.stroke();
        for (let x = g1 + 24; x < g2 - 8; x += 48) {          // arrowheads point the way the gene is read
          ctx.beginPath(); ctx.moveTo(x + 3, y - 4); ctx.lineTo(x - 2, y); ctx.lineTo(x + 3, y + 4); ctx.stroke();
        }
        ctx.textAlign = "left";
        ctx.fillText("HBB", Math.max(14, toX(GENE.start, width)), 42);
      }
      for (const e of EXONS) {
        const x1 = toX(e.start - 0.5, width), x2 = toX(e.end + 0.5, width);
        if (x2 < 0 || x1 > width) continue;
        ctx.fillRect(x1, y - 5, x2 - x1, 10);
        const c1 = toX(e.cdsStart - 0.5, width), c2 = toX(e.cdsEnd + 0.5, width);
        ctx.fillRect(c1, y - 11, c2 - c1, 22);
        ctx.textAlign = "center";
        label("exon " + e.n, (Math.max(x1, 30) + Math.min(x2, width - 30)) / 2, y + 24);
      }

      // Variants: shape and colour both carry the ClinVar classification
      const zoomedIn = span < 600;
      for (const m of markers) {
        if (m.x < -10 || m.x > width + 10) continue;
        const isSelected = m === chosen;
        ctx.fillStyle = pathogenic(m.v) ? COLOUR.pathogenic : COLOUR.benign;
        ctx.beginPath();
        if (pathogenic(m.v)) { ctx.moveTo(m.x, m.y - 7); ctx.lineTo(m.x + 7, m.y + 6); ctx.lineTo(m.x - 7, m.y + 6); ctx.closePath(); }
        else { ctx.arc(m.x, m.y, 6, 0, 2 * Math.PI); }
        ctx.fill();
        ctx.strokeStyle = COLOUR.ink;
        ctx.stroke();
        if (isSelected) { ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(m.x, m.y, 11, 0, 2 * Math.PI); ctx.stroke(); ctx.lineWidth = 1; }
        if (isSelected || zoomedIn) {
          ctx.fillStyle = COLOUR.ink;
          ctx.textAlign = "left";
          ctx.fillText(m.v.short, m.x + (isSelected ? 15 : 10), m.y);
        }
      }
    }

    // Move the view. Sliding helps people follow the change, but motion can make others unwell,
    // so it is skipped when the device asks for reduced motion.
    function moveTo(target) {
      cancelAnimationFrame(frame);
      if (reduceMotion.matches) { view = Object.assign({}, target); draw(); return; }
      const from = Object.assign({}, view), started = performance.now(), duration = 450;
      const stepFrame = now => {
        const t = Math.min(1, (now - started) / duration);
        const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
        view = { start: from.start + (target.start - from.start) * ease, end: from.end + (target.end - from.end) * ease };
        draw();
        if (t < 1) frame = requestAnimationFrame(stepFrame);
      };
      frame = requestAnimationFrame(stepFrame);
    }

    function select(id) {
      selected = VARIANTS.find(v => v.id === id) || null;
      document.querySelectorAll("#choices button[data-id]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.id === id)));
      document.querySelectorAll("#rows tr").forEach(tr => {
        const on = tr.dataset.id === id;
        tr.classList.toggle("selected", on);
        tr.querySelector(".state").textContent = on ? " (selected)" : "";
      });
      if (selected) {
        moveTo({ start: selected.pos - 80, end: selected.pos + 80 });
        // One short message per action: "polite" waits until the screen reader has finished speaking
        feedback.textContent = selected.short + " (" + selected.id + "), chr11:" + fmt(selected.pos) + " " + selected.change +
          ", " + selected.c + " in " + selected.where + (selected.p === "p.?" ? "" : ", " + selected.p) +
          ". ClinVar: " + selected.clinvar + ". The picture now shows 160 bases around it.";
      } else {
        moveTo(WHOLE);
        feedback.textContent = "The picture shows the whole gene, chr11:" + fmt(GENE.start) + "-" + fmt(GENE.end) + ".";
      }
    }

    // Keyboard route: one real button per variant, plus one to zoom out
    const list = document.getElementById("choices");
    for (const v of VARIANTS) {
      const li = document.createElement("li");
      const b = document.createElement("button");
      b.type = "button";
      b.dataset.id = v.id;
      b.setAttribute("aria-pressed", "false");
      const shape = document.createElement("span");
      shape.className = "shape " + (pathogenic(v) ? "pathogenic" : "benign");
      shape.setAttribute("aria-hidden", "true");
      shape.textContent = pathogenic(v) ? "▲" : "●";
      b.append(shape, " " + v.short);
      b.addEventListener("click", () => select(v.id));
      li.append(b);
      list.append(li);
    }
    const whole = document.createElement("li");
    whole.innerHTML = '<button type="button" id="whole">Show the whole gene</button>';
    list.append(whole);
    document.getElementById("whole").addEventListener("click", () => select(null));

    // Text alternative: the same records as a table
    const rows = document.getElementById("rows");
    for (const v of VARIANTS) {
      const tr = document.createElement("tr");
      tr.dataset.id = v.id;
      for (const text of [v.id, "chr11:" + fmt(v.pos) + " " + v.change, v.c + " (" + v.where + ")", v.p, v.name, v.clinvar]) {
        const td = document.createElement("td");
        td.textContent = text;
        tr.append(td);
      }
      const state = document.createElement("span");
      state.className = "state visually-hidden";
      tr.cells[0].append(state);
      rows.append(tr);
    }

    // Pointer route: a click selects the nearest variant within 15 pixels
    canvas.addEventListener("click", e => {
      const box = canvas.getBoundingClientRect();
      const px = e.clientX - box.left, py = e.clientY - box.top;
      let best = null, distance = 15;
      for (const m of layout(canvas.clientWidth)) {
        const d = Math.hypot(m.x - px, m.y - py);
        if (d < distance) { best = m; distance = d; }
      }
      if (best) select(best.v.id);
    });

    function describeMotion() {
      motionNote.textContent = reduceMotion.matches
        ? "Your device asks for reduced motion, so the picture jumps to each variant instead of sliding."
        : "The picture slides to each variant. With reduced motion turned on in your device's settings, it jumps instead.";
    }
    reduceMotion.addEventListener("change", describeMotion);
    describeMotion();
    new ResizeObserver(draw).observe(canvas);
    draw();
  </script>
</body>
</html>

How it works:

  • The canvas has role="img", an aria-label that sums up the picture, and aria-describedby pointing to the caption, which also explains the shapes. It is not focusable, so it cannot trap the keyboard.
  • The buttons are one <button> per variant, with aria-pressed saying which one is chosen, plus a button to zoom out. A click on the picture selects the nearest variant within 15 pixels, for pointer users.
  • The live message is an empty <p role="status" aria-live="polite"> in the page from the start. Each choice writes one sentence into it.
  • The table holds the same records. The chosen row is marked in text (“selected”, hidden visually) as well as by its background, and the table’s box can be scrolled from the keyboard.
  • Shape and colour carry the ClinVar classification together: orange triangles for pathogenic variants and blue circles for benign ones, with dark outlines. Markers that would overlap move onto separate rows.
  • Motion follows the device’s setting. window.matchMedia("(prefers-reduced-motion: reduce)") decides whether the view slides to a variant or jumps, and a sentence on the page says which.

Choose colours that survive colour blindness

Red-green colour vision deficiency affects about 8% of men and 0.4% of women of European ancestry [5]: about eight students in a class of 200 with equal numbers of men and women. WCAG asks that colour is never the only way information is shown, that text has a contrast ratio of at least 4.5:1, and that graphics and controls have at least 3:1 against their surroundings [1].

  • Avoid rainbow and red-green colour maps, which distort data and can be unreadable for people with colour vision deficiency [6].
  • For continuous data, use a map designed for colour vision deficiency, such as cividis [7]. For differences either side of zero, our chart examples use ColorBrewer’s diverging RdBu scheme, centred on zero. Plotly.js’s built-in “RdBu” runs the other way, from blue to red, with uneven steps, so give its colour stops explicitly.
  • For categories, start from Okabe and Ito’s palette or ColorBrewer’s [3]. The example’s blue (#2a78d6) and orange (#eb6834) stayed clearly distinct when we simulated colour vision deficiency for our chart research, and both have more than 3:1 contrast with white (4.4:1 and 3.2:1).
  • Repeat the meaning in another channel: shape, a label, a line style or position [3]. Write labels in dark ink rather than in the series colours.
  • Check in greyscale first [8], then with a simulator such as Coblis or Viz Palette [9].
  • Change library defaults. IGV’s default colours are not always accessible [3], and the ClinVar tracks used in genome browsers colour variants red and green.

Respect reduced motion, and let students pause

Anything that starts moving by itself, lasts more than five seconds and sits alongside other content needs a way to pause, stop or hide it [1]. 3Dmol.js’s data-spin attribute, for example, sets a molecule spinning, so add a pause button next to it. Motion that follows a click, such as a view sliding to a new place, should be easy to switch off: WCAG asks for this at level AAA [1].

Devices let people ask for less motion, because animations such as scaling or panning large objects can cause discomfort for people with vestibular motion disorders (MDN). Read the setting in CSS with @media (prefers-reduced-motion: reduce), or in JavaScript:

// true when the device asks for less motion; listen for changes too
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
function moveTo(target) {
  if (reduceMotion.matches) jumpTo(target);   // show the new view at once
  else slideTo(target);                        // animate, as the example does
}

Know what each library does

We found the following with the keyboard and by reading each library’s code and output, on 15 and 16 October 2026. We did not test every library with a screen reader.

Library Draws with Keyboard and screen readers What to add
igv.js 3.8.9 Canvas The search box works from the keyboard. F and B jump to the next and previous feature on a selected track, but selecting one needs a pointer. The source has only 3 ARIA labels; screen readers cannot read the tracks. Buttons for the key places, a feature table, and a text version of each task
JBrowse 2 4.3.0 Canvas tracks Buttons and menus have ARIA labels (75 in the script); the tracks are pictures. As for igv.js
Nightingale 5.11.0 and react-msaview 8.4.1 Canvas The alignment is a picture. A text read-out of the chosen column; a plain SVG viewer with a native slider is the accessible fallback
SeqViz 3.10.25 SVG The bases, amino acids and labels are text in the page, but a screen reader meets the sequence one letter at a time. Tab does not reach the viewer. A sentence that states the point, the sequence as plain text, and buttons for the key bases
Mol* 5.12.0 and PDBe Mol* 3.12.0 WebGL The viewer has its own buttons and panels around the picture. Labels in 3D can hide behind nearer atoms. role="region" on the container, buttons for preset views, the key point in text, a residue table
NGL 2.5.0 WebGL Without WebGL, a blank box. Its labels cannot show Greek letters. A WebGL check, and labels in HTML
3Dmol.js 2.5.5 WebGL data-spin starts rotation on its own. A pause control, and a WebGL check
Cytoscape.js 3.34.3 Canvas Nothing for keyboard or screen-reader users. A button per node, the edge list as a table, details in a live region, arrow shapes and line styles as well as colour
phylotree.js 2.6.0 SVG Labels are text in the page, but its menu cannot be reached by keyboard and tips cannot take focus. Checkboxes for choosing tips, and Escape to close the menu
Phylocanvas.gl 1.64.1 WebGL Nothing in the page and no keyboard use; blank without WebGL. A text version of the tree and checkboxes
d3, hand-written tree SVG Whatever you build: focusable tips with ARIA roles, Enter and Space. Nothing beyond your own code
Vega-Lite 6.4.3 SVG Writes ARIA labels for the chart, its axes and every mark. The keyboard reaches only its actions menu. aria: false on dense background layers, a summary sentence and a table. Its canvas renderer loses the per-mark labels.
Plotly.js 4.1.1 SVG Its toolbar buttons take focus; there is no ARIA on the data. A label on the container, a summary and a table
Observable Plot 0.6.17 SVG ariaDescription, ariaLabel and ariaHidden options; tips need a pointer. A summary and a table
webR and Pyodide code boxes HTML Code is in a labelled text box, status in a live region, output as text. Alt text for each plot that describes that plot
JupyterLite HTML JupyterLab’s own keyboard and screen-reader support, which we did not test. Notebooks with headings, alt text and tables
Galaxy HTML Galaxy has cut the number of mouse-only steps and improved keyboard navigation [10]. Galaxy Training Network tutorials aim for WCAG 2.0 AA [11]. Links straight to the tool, workflow or history students need

Even where the keyboard can move a view, the data drawn inside canvas and WebGL views stay out of reach of screen readers. That is why the routes above, a table, buttons and a live message, matter more than any library setting.

Test it yourself

  • Keyboard only. Unplug the mouse and do the whole task with Tab, Shift+Tab, Enter, Space, the arrow keys and Escape.
  • A screen reader. NVDA on Windows and VoiceOver on macOS are free [3].
  • Zoom to 200%. Text must still fit and work at that size [1].
  • Colour. Look at the page in greyscale and in a colour-blindness simulator.
  • Automated checkers find some problems quickly; Galaxy’s own browser tests use axe-core. They cannot tell you whether the task makes sense without the picture [2].
  • People. Design and test with disabled users from the start [3].

References

  1. W3C (2023). Web Content Accessibility Guidelines (WCAG) 2.2. W3C Recommendation, 5 October 2023; current version 12 December 2024. w3.org/TR/WCAG22
  2. L’Yi S, Zhang HG, Mar AP, et al. (2025). A comprehensive evaluation of life sciences data resources reveals significant accessibility barriers. Scientific Reports 15: 23676. doi:10.1038/s41598-025-08731-7
  3. Smits TC, Weru L, Gehlenborg N, L’Yi S (2025). Ten simple rules for making biomedical data resources accessible. PLOS Computational Biology 21(11): e1013657. doi:10.1371/journal.pcbi.1013657
  4. Smits TC, L’Yi S, Mar AP, Gehlenborg N (2024). AltGosling: automatic generation of text descriptions for accessible genomics data visualization. Bioinformatics 40(12): btae670. doi:10.1093/bioinformatics/btae670
  5. Birch J (2012). Worldwide prevalence of red-green color deficiency. Journal of the Optical Society of America A 29(3): 313–320. doi:10.1364/JOSAA.29.000313
  6. Crameri F, Shephard GE, Heron PJ (2020). The misuse of colour in science communication. Nature Communications 11: 5444. doi:10.1038/s41467-020-19160-7
  7. Nuñez JR, Anderton CR, Renslow RS (2018). Optimizing colormaps with consideration for color vision deficiency to enable accurate interpretation of scientific data. PLOS ONE 13(7): e0199239. doi:10.1371/journal.pone.0199239
  8. Marai GE, Pinaud B, Bühler K, Lex A, Morris JH (2019). Ten simple rules to create biological network figures for communication. PLOS Computational Biology 15(9): e1007244. doi:10.1371/journal.pcbi.1007244
  9. Hattab G, Rhyne TM, Heider D (2020). Ten simple rules to colorize biological data visualization. PLOS Computational Biology 16(10): e1008259. doi:10.1371/journal.pcbi.1008259
  10. Galaxy Community (2024). The Galaxy platform for accessible, reproducible, and collaborative data analyses: 2024 update. Nucleic Acids Research 52(W1): W83–W94. doi:10.1093/nar/gkae410
  11. Galaxy Training Network. Accessibility. Read 15 October 2026. training.galaxyproject.org