Drawing phylogenetic trees

Draw a tree from Newick text with phylotree.js, or with d3 and a page of your own code, and turn it into an exercise. For outbreak trees, embed Nextstrain at a fixed date. Use Phylocanvas.gl only for very large trees.

Which tree viewer to use

Feature phylotree.js d3, by hand Phylocanvas.gl Nextstrain or Microreact
What it is A tree library built on d3, drawing SVG Your own code: a short Newick parser and d3’s tree layout A tree library that draws with WebGL Outbreak websites, shown in an iframe
Download 209 kB with underscore (66 kB compressed) 280 kB for d3 (94 kB compressed) 637 kB (176 kB compressed) About 0.8 MB for Nextstrain, 2 MB for Microreact
Best for Most pages: names as real text, and a menu to collapse, reroot and select Quizzes, and trees that must be exactly right and work by keyboard Trees with thousands of tips Outbreaks, with a map, dates and metadata beside the tree
Limits Two bugs to work around; its menu works by mouse only 100 to 200 lines of code to look after Needs WebGL; nothing for screen readers or keyboards You control only what the address sets; both load their own analytics
Licence MIT ISC (d3) MIT Free to use; cite their papers

Most examples use the same small tree, so that you can compare the tools: nine primates and the mouse, from a study that used the DNA around 2,030 ultraconserved elements, regions that have barely changed across distantly related species [1]. It is tree Tr49836 in TreeBASE, a Bayesian consensus tree whose branch lengths are substitutions per site and whose node labels are posterior probabilities, all 1.00. TreeBASE does not allow other websites to read its files, so the primate examples carry the Newick text in the page. The outbreak examples use Nextstrain’s Zika virus dataset. All examples were tested on 15 October 2026 with phylotree.js 2.6.0, underscore 1.13.8, d3 7.9.0 and Phylocanvas.gl 1.64.1.

Start with a simple tree

phylotree.js reads Newick text and draws the tree as SVG, so the names are real text that you can style, search, copy and print [2]. It needs the underscore library, loaded first, and a few lines of code.

Code phylotree-basic.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>phylotree.js: a primate tree drawn from Newick text</title>
  <!-- phylotree.js 2.6.0 from jsDelivr, with the underscore library it needs, loaded first.
       The integrity hashes stop a changed file from running. -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/phylotree@2.6.0/dist/phylotree.css"
        integrity="sha384-4Z3mkjcoeAfMJix9nxpdTzuemcgW8Y3+rBvP/S0sqGTJiZhcY1o846BvzENN4EuY"
        crossorigin="anonymous">
  <script src="https://cdn.jsdelivr.net/npm/underscore@1.13.8/underscore-umd-min.js"
          integrity="sha384-qZAXjHQr1t2ESpYPWL8zxAxRilTky3AarPVF2SilaNDgJKUW8T32DcW8OFgqZuGM"
          crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/phylotree@2.6.0/dist/phylotree.min.js"
          integrity="sha384-nwdyfm9qI2lt07VSQvi/ECbWp+zTTfitL8SjTsVZ1KiZHXHlkzTZq42ojvJItGSW"
          crossorigin="anonymous"></script>
  <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 8px; }
    p, details { margin: 4px 0 0; font-size: 14px; color: #3b4659; }
    code { font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
    .phylotree-node-text { font-style: italic; }   /* species names in italics */
  </style>
</head>
<body>
  <h1>Nine primates and the mouse</h1>
  <div id="tree"></div>
  <p>Faircloth and colleagues (2012), TreeBASE tree Tr49836: a Bayesian tree from 2,030 ultraconserved elements, with the mouse as the outgroup.</p>
  <details><summary>The tree as Newick text</summary><code id="newick">((((((Pongo_abelii:0.005033,((Homo_sapiens:0.002481,Pan_troglodytes:0.002181)1.00:4.86E-4,Gorilla_gorilla:0.002762)1.00:0.001866)1.00:4.16E-4,Nomascus_leucogenys:0.005591)1.00:0.001522,(Papio_hamadryas:0.001943,Macaca_mulatta:0.001852)1.00:0.004712)1.00:0.002352,Callithrix_jacchus:0.012341)1.00:0.009184,Otolemur_garnettii:0.024586):0.034538,Mus_musculus:0.034538);</code></details>

  <script>
    // The tree as stored in TreeBASE, with its taxon numbers replaced by names. A number after a colon
    // is a branch length; the 1.00 after a closing bracket is that group's posterior probability.
    // Keeping the text in the page gives readers a text version of the picture.
    const newick = document.getElementById("newick").textContent;

    const box = document.getElementById("tree");
    const tree = new phylotree.phylotree(newick);

    tree.render({
      container: "#tree",
      width: Math.max(300, Math.min(640, box.clientWidth)),   // no wider than the page, to fit phones
      height: 300,
      "left-right-spacing": "fit-to-size",     // stretch the tree to fill the width and height
      "top-bottom-spacing": "fit-to-size",
      "show-scale": false                      // in 2.6.0 the built-in scale bar disagrees with the branches
    });

    box.appendChild(tree.display.show());      // render() draws the tree; you put it on the page

    // Write "Homo sapiens" rather than "Homo_sapiens"
    tree.display.nodeLabel(node => (node.data.name || "").replace(/_/g, " "));
  </script>
</body>
</html>

How it works:

  • new phylotree.phylotree(newick) reads the Newick text. It understands quoted names such as 'Homo sapiens'.
  • render() lays out the tree and builds the SVG, but does not put it on the page. appendChild(tree.display.show()) does that.
  • "fit-to-size" stretches the tree to the width and height you give. If the names do not fit, phylotree.js makes the font smaller, as it does on a phone.
  • "show-scale": false turns off the built-in scale bar, which is wrong in this version (see below).
  • nodeLabel() sets the text at each node. Here it turns Homo_sapiens into Homo sapiens.

To load a tree from another website, fetch its text first. The Open Tree of Life serves this same tree, with its branch lengths but without support values, and lets other websites read it [3]:

fetch("https://api.opentreeoflife.org/v3/study/pg_2579/tree/tree5983.tre?tip_label=ot:ottTaxonName")
  .then(response => response.text())
  .then(newick => {
    const tree = new phylotree.phylotree(newick);   // then render() and show() as above
  });

A .nwk file stored next to your page on GitHub Pages works the same way.

phylotree.js has a menu on every internal node, with commands to collapse a clade, reroot, select branches and hide a subtree, and functions that do the same jobs from your own buttons. This example adds buttons, a search box that highlights species, and the workarounds for the two bugs described in the next section.

Code phylotree-explore.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>phylotree.js: collapse, reroot and search a primate tree</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/phylotree@2.6.0/dist/phylotree.css"
        integrity="sha384-4Z3mkjcoeAfMJix9nxpdTzuemcgW8Y3+rBvP/S0sqGTJiZhcY1o846BvzENN4EuY"
        crossorigin="anonymous">
  <!-- Styles for phylotree's node menu, so that the page does not need Bootstrap -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/phylotree@2.6.0/src/render/styles/phylotree-menus.css"
        integrity="sha384-unxZUfcSszDiWo3XKUhEgIr0r1lTYWKQEzU3CLio37BMoAAA3ckjRmzkh/pHIBw6"
        crossorigin="anonymous">
  <script src="https://cdn.jsdelivr.net/npm/underscore@1.13.8/underscore-umd-min.js"
          integrity="sha384-qZAXjHQr1t2ESpYPWL8zxAxRilTky3AarPVF2SilaNDgJKUW8T32DcW8OFgqZuGM"
          crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/phylotree@2.6.0/dist/phylotree.min.js"
          integrity="sha384-nwdyfm9qI2lt07VSQvi/ECbWp+zTTfitL8SjTsVZ1KiZHXHlkzTZq42ojvJItGSW"
          crossorigin="anonymous"></script>
  <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 6px; font-size: 14px; color: #3b4659; }
    .controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 0 0 6px; font-size: 14px; }
    button, input, select { font: inherit; font-size: 14px; padding: 3px 8px; }
    #tree { position: relative; }                       /* phylotree places its node menu inside this box */
    .phylotree-node-text { font-style: italic; }
    .internal-node .phylotree-node-text { font-style: normal; fill: #3b4659; }
    .node.hl .phylotree-node-text { fill: #b8322f; font-weight: 700; }
    #note { min-height: 1.5em; margin: 4px 0 0; color: #172033; }
    details { font-size: 14px; color: #3b4659; }
    code { font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
  </style>
</head>
<body>
  <h1>Collapse, reroot and search the primate tree</h1>
  <p>Click a node circle for phylotree's own menu (mouse only). The buttons do the same jobs from the keyboard.</p>
  <div class="controls">
    <button type="button" id="ladder">Ladderise</button>
    <button type="button" id="collapse">Collapse or expand the great apes</button>
    <label for="root-on">Root on</label>
    <select id="root-on"></select>
    <button type="button" id="reroot">Reroot</button>
    <button type="button" id="original">Original tree</button>
  </div>
  <div class="controls">
    <label for="search">Highlight</label>
    <input id="search" type="search" placeholder="e.g. pan, macaca" size="16">
    <label><input type="checkbox" id="made-up"> Made-up support values, to test rerooting</label>
  </div>
  <div id="tree"></div>
  <svg id="scale" width="320" height="22" role="img" aria-label="Scale bar"></svg>
  <p id="note" role="status" aria-live="polite"></p>
  <details><summary>The tree as Newick text</summary><code id="newick">((((((Pongo_abelii:0.005033,((Homo_sapiens:0.002481,Pan_troglodytes:0.002181)1.00:4.86E-4,Gorilla_gorilla:0.002762)1.00:0.001866)1.00:4.16E-4,Nomascus_leucogenys:0.005591)1.00:0.001522,(Papio_hamadryas:0.001943,Macaca_mulatta:0.001852)1.00:0.004712)1.00:0.002352,Callithrix_jacchus:0.012341)1.00:0.009184,Otolemur_garnettii:0.024586):0.034538,Mus_musculus:0.034538);</code></details>

  <script>
    const NEWICK = document.getElementById("newick").textContent;   // Faircloth et al. (2012), TreeBASE Tr49836
    // Every posterior probability in this tree is 1.00, so a value on the wrong branch looks the same as one
    // on the right branch. For testing, replace them with made-up values from 0.91 to 0.97.
    let count = 0;
    const MADE_UP = NEWICK.replace(/1\.00/g, () => (0.91 + 0.01 * count++).toFixed(2));

    const box = document.getElementById("tree");
    const note = document.getElementById("note");
    const madeUp = document.getElementById("made-up");
    const pretty = name => name.replace(/_/g, " ");
    let tree, rerooted = false, highlight = [], rightPlaces;

    // What to write at each node. phylotree keeps a support value as the "name" of an internal node,
    // and names the root "root", so only numbers are shown. After a reroot, version 2.6.0 leaves
    // support values on the wrong branches, so hide them unless we are testing.
    function label(node) {
      if (!node.children) return pretty(node.data.name);
      if (rerooted && !madeUp.checked) return "";
      return /^\d*\.?\d+$/.test(node.data.name || "") ? node.data.name : "";
    }

    // Called for every node when phylotree draws: mark tips whose names contain a search word
    function styleNode(element, node) {
      const name = (node.data.name || "").toLowerCase();
      element.classed("hl", !node.children && highlight.some(word => name.includes(word)));
    }

    function build() {
      tree = new phylotree.phylotree(madeUp.checked ? MADE_UP : NEWICK);
      rerooted = false;
      tree.render({
        container: "#tree",
        width: Math.max(300, Math.min(700, box.clientWidth)), height: 320,
        "left-right-spacing": "fit-to-size",
        "top-bottom-spacing": "fit-to-size",
        "internal-names": true,          // draw internal node labels (here, the support values)
        "show-scale": false,             // its own scale bar is wrong in 2.6.0, so we draw one below
        brush: false,                    // no drag-to-select box
        "node-styler": styleNode
      });
      box.innerHTML = "";
      box.appendChild(tree.display.show());
      rightPlaces = supportsByBranch();   // where each support value belongs, read before any reroot
      setUp();
    }

    // Each branch splits the tips into two groups, whatever the rooting. Name each support value's branch by
    // the smaller group (by the group with the first name, in a tie) so that it can be found again after a reroot.
    function supportsByBranch() {
      const all = tree.getTips().map(n => n.data.name).sort();
      const found = new Map();
      for (const n of tree.nodes.descendants()) {
        if (!n.children || !n.parent || !/^\d*\.?\d+$/.test(n.data.name || "")) continue;
        const below = n.leaves().map(l => l.data.name).sort();
        const rest = all.filter(name => !below.includes(name));
        const side = below.length < rest.length || (below.length === rest.length && below[0] < rest[0]) ? below : rest;
        found.set(side.join(","), n.data.name);
      }
      return found;
    }

    // phylotree throws its display away after every reroot and builds a new one,
    // so the labels and the scale bar must be set up again each time.
    function setUp() {
      tree.display.nodeLabel(label);
      tree.display.on("rendered", drawScaleBar);
      drawScaleBar();
      if (!rerooted) note.textContent = "";
      else if (!madeUp.checked) note.textContent = "Rerooted. Support values are hidden, because phylotree.js 2.6.0 can put them on the wrong branches.";
      else {
        const now = supportsByBranch();
        const wrong = [...rightPlaces].filter(([branch, value]) => now.get(branch) !== value).length;
        note.textContent = `Rerooted. ${wrong} of the ${rightPlaces.size} made-up values are no longer on the branch they belong to.`;
      }
    }
    box.addEventListener("reroot", () => { rerooted = true; setUp(); });   // phylotree sends this event to its container

    // A scale bar measured from the drawing itself, so it always agrees with the branches:
    // pixels per unit = (horizontal distance from the root to a tip) / (sum of branch lengths in between)
    function drawScaleBar() {
      const nodes = tree.nodes.descendants();
      const root = nodes.find(n => !n.parent);
      const depth = n => n.ancestors().reduce((sum, a) => sum + (a.parent ? +tree.branch_length_accessor(a) || 0 : 0), 0);
      const tips = nodes.filter(n => !n.children && Number.isFinite(n.screen_x));
      const far = tips.reduce((a, b) => (depth(b) > depth(a) ? b : a));
      const pixels = 0.01 * (far.screen_x - root.screen_x) / depth(far);
      document.getElementById("scale").innerHTML =
        `<line x1="1" x2="${1 + pixels}" y1="8" y2="8" stroke="#172033" stroke-width="2"/>` +
        `<text x="${8 + pixels}" y="12" font-size="12" fill="#172033">0.01 substitutions per site</text>`;
    }

    // phylotree closes its menu only on clicks inside the tree. Close it on Escape and outside clicks too.
    const closeMenu = () => { const m = box.querySelector(".phylotree-context-menu"); if (m) m.style.display = "none"; };
    document.addEventListener("keydown", e => { if (e.key === "Escape") closeMenu(); });
    document.addEventListener("click", e => { if (!box.contains(e.target)) closeMenu(); });

    // Ladderise: sort each node's children by their number of tips, alternating direction on each click
    let direction = 1;
    document.getElementById("ladder").addEventListener("click", () => {
      tree.resortChildren((a, b) => direction * (a.leaves().length - b.leaves().length));
      direction = -direction;
    });

    document.getElementById("collapse").addEventListener("click", () => {
      const greatApes = tree.mrca(["Homo_sapiens", "Pongo_abelii"]);
      tree.display.toggleCollapse(greatApes).update();
    });

    const rootOn = document.getElementById("root-on");
    function fillRootList() {
      rootOn.innerHTML = tree.getTips().map(n => n.data.name).sort()
        .map(name => `<option value="${name}">${pretty(name)}</option>`).join("");
      rootOn.value = "Homo_sapiens";
    }
    document.getElementById("reroot").addEventListener("click", () => {
      tree.reroot(tree.getNodeByName(rootOn.value));   // rebuilds the display and sends a "reroot" event
    });
    document.getElementById("original").addEventListener("click", build);
    madeUp.addEventListener("change", build);

    document.getElementById("search").addEventListener("input", e => {
      highlight = e.target.value.split(",").map(s => s.trim().toLowerCase()).filter(Boolean);
      tree.display.update();
    });

    build();
    fillRootList();
  </script>
</body>
</html>

These are the parts to reuse in your own pages:

  • The menu needs phylotree-menus.css from the package’s src folder if your page does not use Bootstrap, and the tree’s container needs position: relative. The menu closes only on a click inside the tree, so the example also closes it on Escape and on clicks elsewhere. It cannot be reached from the keyboard, which is why the buttons are there.
  • Collapsing: tree.mrca([...]) finds the most recent common ancestor of some species. Calling toggleCollapse(node) on tree.display, then update(), collapses or expands its clade. A collapsed clade is drawn as a grey shape with no label.
  • Ladderising: tree.resortChildren(compare) sorts each node’s branches; the example sorts them by number of tips.
  • Rerooting: tree.reroot(node) puts the root halfway along the branch above that node. phylotree.js then throws the drawing away, builds a new one and sends a reroot event to the container, so anything set on the old drawing, such as nodeLabel, has to be set again (setUp() in the example).
  • Highlighting: the node-styler function runs for every node at each redraw. Here it marks the tips whose names contain a search word.

Work around two bugs in phylotree.js 2.6.0

Both bugs give wrong answers without any error message, which matters in a page meant to teach students to read trees. We found them in version 2.6.0, the latest release on 15 October 2026, and the same code was on the project’s main branch on that date.

The scale bar is too long. phylotree.js places the nodes at 80% of the scale it uses for its scale bar (scales[1]*.8 in src/render/draw.js), and with "fit-to-size" spacing its bar also ignores the room taken by the names. In our tests the bar was 1.17 times too long with the default spacing, and 1.65 to 1.91 times too long with "fit-to-size", depending on the width. Turn it off with "show-scale": false and draw your own from the drawing itself: the distance in pixels from the root to a tip, divided by the sum of the branch lengths in between, gives pixels per unit. drawScaleBar() in the example does this, and its bar matched the branches exactly. Whatever you use, check the scale bar against one branch of known length.

Rerooting moves support values onto the wrong branches. Newick stores a support value as the label of the node below its branch. When phylotree.js reroots, it turns round the branches between the old root and the new one and moves their lengths, but it leaves each label on its node, so every support value on that path ends up one branch nearer the new root.

Every value in the primate tree is 1.00, so you cannot see this in it. The example therefore has a switch that gives the seven internal branches made-up values from 0.91 to 0.97, and counts how many are out of place after a reroot. Rooted on humans, six of the seven were on the wrong branch. Rooted on the mouse or the galago, with no labelled branch between them and the old root, none moved.

The example hides support values after a reroot unless the switch is on. You can also remove the reroot command from the menu (reroot: false), or reroot the tree with your own code before handing it to phylotree.js, as the next example does. When a new version comes out, the same switch shows whether the bug has been fixed.

Draw the tree yourself with d3

d3 has no tree format of its own, but it lays out any hierarchy, so a 20-line Newick parser and a few dozen lines of drawing code give you a tree [4]. You decide exactly what happens, which suits quizzes and anything that has to be correct. This example, about 120 lines of code in all, collapses clades by mouse or keyboard, ladderises, shows branch lengths, and reroots with every support value kept on its own branch. The same made-up values let you check.

Code d3-tree.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>d3: a phylogenetic tree drawn by hand, with rerooting that keeps support values on their branches</title>
  <!-- d3 7.9.0 from jsDelivr. The integrity hash stops a changed file from running. -->
  <script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"
          integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i"
          crossorigin="anonymous"></script>
  <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 6px; font-size: 14px; color: #3b4659; }
    .controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 0 0 6px; font-size: 14px; }
    button, select { font: inherit; font-size: 14px; padding: 3px 8px; }
    svg { display: block; overflow: visible; }
    .branch { fill: none; stroke: #6b7385; stroke-width: 1.5px; }
    .tip { font: italic 13px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; fill: #172033; }
    .support, .length { font-size: 10px; fill: #3b4659; paint-order: stroke; stroke: #fff; stroke-width: 3px; }   /* white halo over lines */
    .node { fill: #fff; stroke: #3b4659; stroke-width: 1.5px; cursor: pointer; }
    .node.collapsed { fill: #3b4659; }
    .node:focus-visible { outline: 2px solid #1f4e9c; outline-offset: 2px; }
    .clade { fill: #e4e8ef; stroke: #6b7385; }
    #note { min-height: 1.5em; margin: 4px 0; color: #172033; }
    details { font-size: 14px; }
    details ul { margin: 2px 0; padding-left: 1.2rem; }
  </style>
</head>
<body>
  <h1>A tree drawn with d3 and a 20-line Newick parser</h1>
  <p>Click a node circle, or Tab to it and press Enter, to collapse or expand that clade.</p>
  <div class="controls">
    <label for="root-on">Root on</label>
    <select id="root-on"></select>
    <button type="button" id="reroot">Reroot</button>
    <button type="button" id="original">Original rooting</button>
    <button type="button" id="ladder">Ladderise</button>
  </div>
  <div class="controls">
    <label><input type="checkbox" id="lengths"> Branch lengths</label>
    <label><input type="checkbox" id="made-up"> Made-up support values, to test rerooting</label>
  </div>
  <div id="tree"></div>
  <p id="note" role="status" aria-live="polite"></p>
  <details><summary>The tree as text</summary><div id="as-text"></div></details>

  <script>
    const NEWICK = "((((((Pongo_abelii:0.005033,((Homo_sapiens:0.002481,Pan_troglodytes:0.002181)1.00:4.86E-4,Gorilla_gorilla:0.002762)1.00:0.001866)1.00:4.16E-4,Nomascus_leucogenys:0.005591)1.00:0.001522,(Papio_hamadryas:0.001943,Macaca_mulatta:0.001852)1.00:0.004712)1.00:0.002352,Callithrix_jacchus:0.012341)1.00:0.009184,Otolemur_garnettii:0.024586):0.034538,Mus_musculus:0.034538);";
    // All the real support values are 1.00. For testing, give each branch its own made-up value.
    let count = 0;
    const MADE_UP = NEWICK.replace(/1\.00/g, () => (0.91 + 0.01 * count++).toFixed(2));
    const pretty = name => name.replace(/_/g, " ");

    // Newick text to nested objects: { name, length, support, children }.
    // A node's length and support describe the branch above it. Quoted labels are not handled.
    function parseNewick(text) {
      const tokens = text.replace(/\[[^\]]*\]/g, "").split(/\s*([;(),:])\s*/);   // drop [comments], split on ; ( ) , :
      const stack = [];
      let node = {};
      for (let i = 0; i < tokens.length; i++) {
        const t = tokens[i], previous = tokens[i - 1];
        if (t === "(") { const child = {}; node.children = [child]; stack.push(node); node = child; }
        else if (t === ",") { const child = {}; stack[stack.length - 1].children.push(child); node = child; }
        else if (t === ")") { node = stack.pop(); }
        else if (t && t !== ":" && t !== ";") {
          if (previous === ":") node.length = parseFloat(t);                    // "4.86E-4" works too
          else if (node.children && !isNaN(t)) node.support = parseFloat(t);    // a number after ")" is a support value
          else node.name = t;
        }
      }
      return node;
    }

    // Put the root in the middle of the branch above `target`. Support values belong to branches,
    // not nodes, so each one is carried along with its branch as the path to the old root turns round.
    function reroot(tree, target) {
      const parentOf = new Map();
      (function walk(n) { (n.children || []).forEach(c => { parentOf.set(c, n); walk(c); }); })(tree);
      const parent = parentOf.get(target);
      if (!parent) return tree;
      // every neighbour of a node, with the length and support of the branch that joins them
      const neighbours = n => (n.children || []).map(c => ({ node: c, length: c.length, support: c.support }))
        .concat(parentOf.has(n) ? [{ node: parentOf.get(n), length: n.length, support: n.support }] : []);
      // rebuild outwards from the new root, never going back the way we came
      function build(n, from, length, support) {
        const kids = neighbours(n).filter(e => e.node !== from).map(e => build(e.node, n, e.length, e.support));
        if (kids.length === 1) {   // the old root had two branches: remove it and join them into one
          const only = kids[0];
          return { ...only, length: (only.length || 0) + (length || 0), support: only.support ?? support };
        }
        return kids.length ? { children: kids, length, support } : { name: n.name, length };
      }
      const half = (target.length || 0) / 2;
      return { children: [build(target, parent, half, target.support), build(parent, target, half, target.support)] };
    }

    const box = document.getElementById("tree");
    const note = document.getElementById("note");
    const svg = d3.select(box).append("svg").attr("role", "group").attr("aria-label", "Phylogenetic tree of nine primates and the mouse");
    let T = parseNewick(NEWICK), nextId = 1, direction = 1;

    function draw(focusId) {
      const W = Math.max(280, Math.min(760, box.clientWidth)), H = 320;
      const M = { top: 12, right: 150, bottom: 42, left: 12 };     // room for names on the right and the axis below
      svg.attr("width", W).attr("height", H).selectAll("*").remove();
      const g = svg.append("g").attr("transform", `translate(${M.left},${M.top})`);

      const all = d3.hierarchy(T);                                    // every node, for sizes and depths
      const shown = d3.hierarchy(T, d => (d.collapsed ? null : d.children));   // stops at collapsed clades
      for (const h of [all, shown]) h.eachBefore(d => { d.dist = (d.parent ? d.parent.dist : 0) + (d.data.length || 0); });
      all.each(d => { if (d.children && !d.data.id) d.data.id = nextId++; });
      d3.cluster().size([H - M.top - M.bottom, 1]).separation(() => 1)(shown);   // spaces tips evenly; only the vertical position (x) is used
      const x = d3.scaleLinear([0, d3.max(all.leaves(), d => d.dist)], [0, W - M.left - M.right]);
      const deepest = new Map(all.descendants().map(d => [d.data, d3.max(d.leaves(), l => l.dist)]));
      const tipCount = new Map(all.descendants().map(d => [d.data, d.leaves().length]));

      // Branches as elbows: from the parent, up or down to the child's height, then across
      g.selectAll(".branch").data(shown.links()).join("path").attr("class", "branch")
        .attr("d", l => `M${x(l.source.dist)},${l.source.x}V${l.target.x}H${x(l.target.dist)}`)
        .append("title").text(l => `Branch length ${l.target.data.length}`);

      // Branch lengths, written only where the branch is long enough to hold them (hover shows the rest)
      if (document.getElementById("lengths").checked) {
        g.selectAll(".length").data(shown.links().filter(l => x(l.target.dist) - x(l.source.dist) > 34))
          .join("text").attr("class", "length").attr("text-anchor", "middle")
          .attr("x", l => (x(l.source.dist) + x(l.target.dist)) / 2).attr("y", l => l.target.x - 3)
          .text(l => d3.format(".4f")(l.target.data.length));
      }

      // A collapsed clade is a triangle that reaches as far as its deepest tip
      const collapsed = shown.descendants().filter(d => d.data.collapsed);
      g.selectAll(".clade").data(collapsed).join("path").attr("class", "clade")
        .attr("d", d => `M${x(d.dist)},${d.x}L${x(deepest.get(d.data))},${d.x - 8}V${d.x + 8}Z`);

      // A support value is stored on the node below its branch; write it just right of that node
      g.selectAll(".support").data(shown.descendants().filter(d => d.children && d.data.support != null))
        .join("text").attr("class", "support").attr("dy", "0.32em")
        .attr("x", d => x(d.dist) + 6).attr("y", d => d.x).text(d => d.data.support.toFixed(2));

      // Internal nodes are buttons that collapse or expand their clade, by mouse or keyboard
      const toggle = d => { d.data.collapsed = !d.data.collapsed; draw(d.data.id); };
      g.selectAll(".node").data(shown.descendants().filter(d => d.data.children && d.parent)).join("circle")
        .attr("class", d => "node" + (d.data.collapsed ? " collapsed" : ""))
        .attr("r", 4).attr("cx", d => x(d.dist)).attr("cy", d => d.x)
        .attr("tabindex", 0).attr("role", "button").attr("data-id", d => d.data.id)
        .attr("aria-label", d => `${d.data.collapsed ? "Expand" : "Collapse"} the clade of ${tipCount.get(d.data)} species`)
        .on("click", (e, d) => toggle(d))
        .on("keydown", (e, d) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(d); } });

      g.selectAll(".tip").data(shown.leaves()).join("text").attr("class", "tip").attr("dy", "0.32em")
        .attr("x", d => (d.data.collapsed ? x(deepest.get(d.data)) : x(d.dist)) + 6).attr("y", d => d.x)
        .text(d => (d.data.collapsed ? `${tipCount.get(d.data)} species` : pretty(d.data.name)));

      // The scale bar is an axis made from the same scale as the branches, so the two cannot disagree
      g.append("g").attr("transform", `translate(0,${H - M.top - M.bottom + 12})`).call(d3.axisBottom(x).ticks(5))
        .append("text").attr("x", x.range()[1]).attr("y", 30).attr("fill", "currentColor")
        .attr("text-anchor", "end").text("substitutions per site");

      // A text version of the same tree, as nested lists, for screen readers and for copying
      const asList = n => n.children
        ? `<li>Clade of ${tipCount.get(n)} species${n.support != null ? ", support " + n.support.toFixed(2) : ""}<ul>${n.children.map(asList).join("")}</ul></li>`
        : `<li><i>${pretty(n.name)}</i></li>`;
      document.getElementById("as-text").innerHTML = `<ul>${asList(T)}</ul>`;

      if (focusId) svg.select(`[data-id="${focusId}"]`).node()?.focus();   // keep keyboard focus after redrawing
    }

    function original() {
      T = parseNewick(document.getElementById("made-up").checked ? MADE_UP : NEWICK);
      note.textContent = "";
      draw();
    }

    // Ladderise: sort every node's children by their number of tips, alternating direction on each click
    function ladderise(n, dir) {
      if (!n.children) return;
      n.children.forEach(c => ladderise(c, dir));
      n.children.sort((a, b) => dir * (d3.hierarchy(a).leaves().length - d3.hierarchy(b).leaves().length));
    }

    const rootOn = document.getElementById("root-on");
    rootOn.innerHTML = d3.hierarchy(T).leaves().map(d => d.data.name).sort()
      .map(name => `<option value="${name}">${pretty(name)}</option>`).join("");
    rootOn.value = "Homo_sapiens";

    document.getElementById("reroot").addEventListener("click", () => {
      const target = d3.hierarchy(T).leaves().find(d => d.data.name === rootOn.value).data;
      T = reroot(T, target);
      note.textContent = `Rooted on the branch to ${pretty(rootOn.value)}. Each support value is still on the branch it belongs to.`;
      draw();
    });
    document.getElementById("original").addEventListener("click", original);
    document.getElementById("made-up").addEventListener("change", original);
    document.getElementById("ladder").addEventListener("click", () => { ladderise(T, direction); direction = -direction; draw(); });
    document.getElementById("lengths").addEventListener("change", () => draw());
    window.addEventListener("resize", () => draw());

    draw();
  </script>
</body>
</html>

How the code works:

  • Reading Newick. parseNewick() splits the text at brackets, commas, colons and semicolons, and builds nested objects. Each node’s length and support describe the branch above it. It does not handle quoted names; use a library’s parser for those.
  • Layout. d3.hierarchy turns the objects into a tree, and d3.cluster spaces the tips evenly down the page. The horizontal position of a node is the sum of the branch lengths from the root.
  • The scale bar is an axis drawn with the same x scale as the branches, so the two cannot disagree.
  • Rerooting. reroot() builds the tree again outwards from the middle of the chosen branch, and each branch carries its own length and support value with it. We rerooted on each of the ten species in turn: all seven made-up values stayed on their branches, and all 45 distances between pairs of species were unchanged.
  • Keyboard. Node circles have tabindex="0" and role="button", and respond to Enter and Space. After each redraw, focus goes back to the same node.
  • A text version. The same tree as nested lists sits under the drawing, for screen readers and for copying.

Turn the tree into a clade quiz

The quiz asks six questions about the primate tree. Students answer by selecting species on the tree with a mouse or the keyboard, and the page explains each wrong answer. Several wrong answers are traps for well-known misreadings of trees, which the educators’ page describes.

The tree is drawn as a cladogram: it shows the order of branching, and its branch lengths mean nothing.

Code clade-quiz.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Clade quiz: read a primate tree by its branching</title>
  <script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"
          integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i"
          crossorigin="anonymous"></script>
  <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 4px; }
    p { margin: 0 0 6px; }
    .help { font-size: 14px; color: #3b4659; }
    .question { border: 1px solid #c3cbd8; border-radius: 6px; padding: 8px 12px; margin: 0 0 6px; }
    .question legend, #progress { font-size: 13px; font-weight: 600; color: #3b4659; padding: 0 4px; }
    #choices label { display: block; margin: 2px 0; }
    .controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 6px 0 0; }
    button { font: inherit; font-size: 14px; padding: 3px 9px; }
    #feedback { min-height: 3em; margin: 6px 0 0; font-weight: 600; }
    #feedback.ok { color: #0b6b50; }
    #feedback.bad { color: #b8322f; }
    svg { display: block; overflow: visible; }
    .branch { fill: none; stroke: #6b7385; stroke-width: 1.5px; }
    .branch.shaded { stroke: #b45309; stroke-width: 3px; }
    .node { fill: #fff; stroke: #3b4659; stroke-width: 1.5px; cursor: pointer; }
    .tip { font: italic 14px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; fill: #172033; cursor: pointer; }
    .tip.chosen { fill: #fff; font-weight: 700; paint-order: stroke; stroke: #1f4e9c; stroke-width: 7px; stroke-linejoin: round; }
    .tip:focus-visible, .node:focus-visible { outline: 2px solid #1f4e9c; outline-offset: 2px; }
    details { font-size: 14px; }
    details ul { margin: 2px 0; padding-left: 1.2rem; }
  </style>
</head>
<body>
  <h1>Clade quiz: nine primates and the mouse</h1>
  <p class="help">Click a name to select it, or Tab to it and press Space. Click a node circle, or press Enter on it, to rotate the branches there. The drawing shows the order of branching only: branch lengths are not to scale.</p>

  <fieldset class="question">
    <legend id="progress"></legend>
    <p id="question"></p>
    <div id="choices"></div>
    <div class="controls">
      <button type="button" id="check">Check answer</button>
      <button type="button" id="clear">Clear selection</button>
      <button type="button" id="shuffle">Rotate branches at random</button>
      <button type="button" id="next">Next question</button>
    </div>
    <p id="feedback" role="status" aria-live="polite"></p>
  </fieldset>

  <div id="tree"></div>
  <details><summary>The tree as text</summary><div id="as-text"></div></details>

  <script>
    // Faircloth et al. (2012), TreeBASE tree Tr49836. Only its branching order is used here.
    const NEWICK = "((((((Pongo_abelii:0.005033,((Homo_sapiens:0.002481,Pan_troglodytes:0.002181)1.00:4.86E-4,Gorilla_gorilla:0.002762)1.00:0.001866)1.00:4.16E-4,Nomascus_leucogenys:0.005591)1.00:0.001522,(Papio_hamadryas:0.001943,Macaca_mulatta:0.001852)1.00:0.004712)1.00:0.002352,Callithrix_jacchus:0.012341)1.00:0.009184,Otolemur_garnettii:0.024586):0.034538,Mus_musculus:0.034538);";

    // "pick" questions are answered by selecting species on the tree, "choose" questions with the buttons.
    // `wrong` holds feedback for particular wrong answers that show a known misconception.
    const QUESTIONS = [
      { kind: "pick", text: "Select the closest relative of humans (<i>Homo sapiens</i>) in this tree.",
        answer: ["Pan_troglodytes"],
        right: "Chimpanzees share a common ancestor with humans that no other species in the tree descends from.",
        hint: "Follow the human branch back to the first node you reach. Which other species descend from that node?" },
      { kind: "pick", text: "Select all of the orangutan's (<i>Pongo abelii</i>) closest relatives. There may be more than one.",
        answer: ["Gorilla_gorilla", "Homo_sapiens", "Pan_troglodytes"],
        right: "Humans, chimpanzees and gorillas all share the same most recent common ancestor with the orangutan, so they are equally closely related to it, wherever their names sit.",
        wrong: { Homo_sapiens: "Humans sit next to the orangutan in this drawing, but the order of the names means nothing. Press Rotate branches at random and another species may sit there. Follow the branches instead." },
        hint: "Follow the orangutan's branch back to the first node. Every other species that descends from that node is one of its closest relatives." },
      { kind: "pick", text: "Is the gibbon (<i>Nomascus leucogenys</i>) more closely related to humans or to the macaque (<i>Macaca mulatta</i>)? Select one of the two.",
        answer: ["Homo_sapiens"],
        right: "The gibbon and humans share a more recent common ancestor, the ancestor of all the apes, than the gibbon and the macaque do.",
        wrong: { Macaca_mulatta: "There are fewer nodes between the gibbon and the macaque, but counting nodes does not measure relatedness. Find the most recent common ancestor of each pair and see which is more recent." },
        hint: "Compare the most recent common ancestor of the gibbon and humans with that of the gibbon and the macaque." },
      { kind: "pick", clade: ["Callithrix_jacchus", "Homo_sapiens"],
        text: "Select every species in the smallest clade that contains both the marmoset (<i>Callithrix jacchus</i>) and humans.",
        answer: ["Callithrix_jacchus", "Gorilla_gorilla", "Homo_sapiens", "Macaca_mulatta", "Nomascus_leucogenys", "Pan_troglodytes", "Papio_hamadryas", "Pongo_abelii"],
        right: "A clade is an ancestor and all of its descendants. These eight are every species descended from the most recent common ancestor of the marmoset and humans." },
      { kind: "choose", text: "The marmoset's lineage branches off nearer the root than the human lineage does. What does that tell you?",
        options: [
          ["Marmosets are more primitive than humans.", "That is reading the tree as a ladder. Both are living species, and both lineages have been evolving for the same length of time since their common ancestor."],
          ["Humans evolved from marmosets.", "Neither living species is the other's ancestor. They share an ancestor that lived long ago."],
          ["Nothing about which species is more advanced.", ""]],
        right: "It tells you only that the marmoset's lineage split from the others before the apes and the Old World monkeys split from each other." },
      { kind: "choose", text: "Press Rotate branches at random two or three times. What changes?",
        options: [
          ["The order of the names, and nothing else.", ""],
          ["Which species are each other's closest relatives.", "Try question 1 again: the answer has not changed. Rotating the branches at a node changes the drawing, not the relationships."],
          ["Which groups of species form clades.", "Rotating the branches at a node never moves a species into or out of a clade."]],
        right: "Rotating the branches at a node is like turning part of a hanging mobile: the order of the names changes, the relationships do not." }
    ];

    // Newick to nested objects (see the d3 tree example for a version that also keeps lengths and support values)
    function parseNewick(text) {
      const tokens = text.split(/\s*([;(),:])\s*/), stack = [];
      let node = {};
      for (let i = 0; i < tokens.length; i++) {
        const t = tokens[i];
        if (t === "(") { const child = {}; node.children = [child]; stack.push(node); node = child; }
        else if (t === ",") { const child = {}; stack[stack.length - 1].children.push(child); node = child; }
        else if (t === ")") { node = stack.pop(); }
        else if (t && t !== ":" && t !== ";" && tokens[i - 1] !== ":" && !node.children) node.name = t;
      }
      return node;
    }

    const T = parseNewick(NEWICK);
    const pretty = name => name.replace(/_/g, " ");
    const chosen = new Set();     // the species the student has selected
    let shaded = null;            // the clade shaded on the tree after checking an answer
    let current = 0, nextId = 1;

    const box = document.getElementById("tree");
    const svg = d3.select(box).append("svg").attr("role", "group").attr("aria-label", "Tree of nine primates and the mouse");
    d3.hierarchy(T).each(d => { if (d.children) d.data.id = nextId++; });

    function draw(focus) {
      const W = Math.max(300, Math.min(640, box.clientWidth)), H = 300;
      const M = { top: 10, right: 160, bottom: 10, left: 10 };
      svg.attr("width", W).attr("height", H).selectAll("*").remove();
      const g = svg.append("g").attr("transform", `translate(${M.left},${M.top})`);
      const root = d3.hierarchy(T);
      // A cladogram: d3.cluster lines the tips up on the right and spaces the nodes by their order of branching
      d3.cluster().size([H - M.top - M.bottom, W - M.left - M.right]).separation(() => 1)(root);
      const inShaded = d => shaded && d.ancestors().some(a => a.data === shaded);

      g.selectAll(".branch").data(root.links()).join("path")
        .attr("class", l => "branch" + (inShaded(l.target) ? " shaded" : ""))
        .attr("d", l => `M${l.source.y},${l.source.x}V${l.target.x}H${l.target.y}`);

      // Tips are checkboxes, so they work by keyboard and screen readers announce their state.
      // They come before the nodes in the page, so Tab reaches the species first.
      g.selectAll(".tip").data(root.leaves()).join("text")
        .attr("class", d => "tip" + (chosen.has(d.data.name) ? " chosen" : ""))
        .attr("x", d => d.y + 8).attr("y", d => d.x).attr("dy", "0.32em").text(d => pretty(d.data.name))
        .attr("tabindex", 0).attr("role", "checkbox").attr("aria-checked", d => chosen.has(d.data.name))
        .attr("data-name", d => d.data.name)
        .on("click", (e, d) => toggle(d.data.name))
        .on("keydown", (e, d) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); toggle(d.data.name); } });

      // Nodes rotate their branches: the same tree, drawn differently
      const rotate = d => { d.data.children.reverse(); draw({ id: d.data.id }); };
      g.selectAll(".node").data(root.descendants().filter(d => d.children)).join("circle")
        .attr("class", "node").attr("r", 5).attr("cx", d => d.y).attr("cy", d => d.x)
        .attr("tabindex", 0).attr("role", "button").attr("data-id", d => d.data.id)
        .attr("aria-label", d => `Rotate the branches at the node joining ${d.leaves().length} species`)
        .on("click", (e, d) => rotate(d))
        .on("keydown", (e, d) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); rotate(d); } });

      // The same tree as nested lists, in the order drawn
      const asList = n => n.children ? `<li>Clade<ul>${n.children.map(asList).join("")}</ul></li>` : `<li><i>${pretty(n.name)}</i></li>`;
      document.getElementById("as-text").innerHTML = `<ul>${asList(T)}</ul>`;

      if (focus) svg.select(focus.id ? `[data-id="${focus.id}"]` : `[data-name="${focus.name}"]`).node()?.focus();
    }

    function toggle(name) {
      chosen.has(name) ? chosen.delete(name) : chosen.add(name);
      shaded = null;
      draw({ name });
    }

    // The most recent common ancestor of some species, in the tree as it stands
    function mrca(names) {
      const leaves = d3.hierarchy(T).leaves().filter(l => names.includes(l.data.name));
      let ancestor = leaves[0];
      while (!leaves.every(l => l.ancestors().includes(ancestor))) ancestor = ancestor.parent;
      return ancestor;
    }

    const feedback = document.getElementById("feedback");
    const say = (text, mood) => { feedback.textContent = text; feedback.className = mood || ""; };
    const list = names => names.map(pretty).join(", ");

    function checkPick(q) {
      const picked = [...chosen].sort();
      if (!picked.length) return say("Select one or more species on the tree first.");
      if (picked.join() === [...q.answer].sort().join()) return say("Correct. " + q.right, "ok");
      if (q.wrong && q.wrong[picked.join()]) return say("Not quite. " + q.wrong[picked.join()], "bad");
      if (q.clade) {
        const ancestor = mrca(picked), below = ancestor.leaves().map(l => l.data.name);
        shaded = ancestor.data;
        draw();
        if (below.length > picked.length)
          return say("Not a clade yet. The most recent common ancestor of your selection, shaded on the tree, also has these descendants: " + list(below.filter(n => !chosen.has(n))) + ".", "bad");
        if (!q.clade.every(n => chosen.has(n))) return say("That is a clade, but it does not contain both the marmoset and humans.", "bad");
        return say("That is a clade with both in it, but not the smallest one. Find their most recent common ancestor and select only the species that descend from it.", "bad");
      }
      if (picked.every(n => q.answer.includes(n))) return say("Some of the right species, but not all of them. " + q.hint, "bad");
      return say("Not quite. " + q.hint, "bad");
    }

    function checkChoose(q) {
      const picked = document.querySelector("#choices input:checked");
      if (!picked) return say("Choose one of the answers first.");
      const why = q.options[+picked.value][1];
      return why ? say("Not quite. " + why, "bad") : say("Correct. " + q.right, "ok");
    }

    function showQuestion() {
      const q = QUESTIONS[current];
      document.getElementById("progress").textContent = `Question ${current + 1} of ${QUESTIONS.length}`;
      document.getElementById("question").innerHTML = q.text;
      document.getElementById("choices").innerHTML = q.kind === "choose"
        ? q.options.map((o, i) => `<label><input type="radio" name="choice" value="${i}"> ${o[0]}</label>`).join("") : "";
      document.getElementById("next").textContent = current === QUESTIONS.length - 1 ? "Start again" : "Next question";
      chosen.clear(); shaded = null; say("");
      draw();
    }

    document.getElementById("check").addEventListener("click", () => {
      const q = QUESTIONS[current];
      q.kind === "pick" ? checkPick(q) : checkChoose(q);
    });
    document.getElementById("clear").addEventListener("click", () => { chosen.clear(); shaded = null; say(""); draw(); });
    document.getElementById("shuffle").addEventListener("click", () => {
      let changed = false;
      d3.hierarchy(T).each(d => { if (d.children && Math.random() < 0.5) { d.data.children.reverse(); changed = true; } });
      if (!changed) T.children.reverse();        // always change something
      draw();
    });
    document.getElementById("next").addEventListener("click", () => { current = (current + 1) % QUESTIONS.length; showQuestion(); });
    window.addEventListener("resize", () => draw());

    showQuestion();
  </script>
</body>
</html>

To build a quiz of your own, reuse these parts:

  • Questions are data. Each entry in QUESTIONS has its text, the right answer as a list of species, and feedback for particular wrong answers. Change the tree and the list to make a quiz of your own.
  • Tips are checkboxes. Each name has role="checkbox", aria-checked and tabindex="0", so students can reach it with Tab and select it with Space, and screen readers say whether it is selected.
  • Checking a clade. mrca() walks up from one selected species until it reaches a node that is an ancestor of all of them. If that node has more descendants than were selected, the selection is not a clade, and the page shades the clade that contains them.
  • Rotating a node reverses the order of its branches. The relationships do not change, which is the point of the last question.
  • A cladogram. d3.cluster with a horizontal size lines the tips up, so students judge the tree by its branching alone.

Embed Nextstrain at a fixed date

Nextstrain shows outbreak trees together with a map, dates and the data behind them [5], and its pages can be shown inside yours in an iframe. Add @ and a date to a dataset’s address and Nextstrain serves the dataset as it was on that date, so the tree does not change during your course. On 15 October 2026, nextstrain.org/zika had 1,045 genomes, updated four days earlier; nextstrain.org/zika@2024-01-01 had 972, built on 8 December 2023.

This example opens nextstrain.org inside the page (about 0.8 MB). Nextstrain's pages load Plausible Analytics.

Code nextstrain-embed.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Nextstrain in a page: Zika virus, fixed at the dataset of 1 January 2024</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 6px; font-size: 14px; color: #3b4659; }
    .controls { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 6px; }
    button { font: inherit; font-size: 14px; padding: 3px 9px; }
    button[aria-pressed="true"] { background: #1f4e9c; color: #fff; border: 1px solid #1f4e9c; border-radius: 3px; }
    code { font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
    iframe { display: block; width: 100%; height: 470px; border: 1px solid #c3cbd8; }
  </style>
</head>
<body>
  <h1>Zika virus in Nextstrain, as the dataset stood on 1 January 2024</h1>
  <div class="controls" role="group" aria-label="Choose a view">
    <button type="button" data-view="d=tree&amp;c=region" aria-pressed="true">Time tree by region</button>
    <button type="button" data-view="d=tree&amp;m=div&amp;c=region">Divergence tree</button>
    <button type="button" data-view="d=tree&amp;l=radial&amp;c=country">Radial, by country</button>
    <button type="button" data-view="d=tree,map&amp;c=region">Tree and map</button>
  </div>
  <p>Address: <code id="address"></code></p>
  <iframe id="nextstrain" title="Nextstrain: Zika virus phylogeny" allow="fullscreen"></iframe>
  <p><a id="open" target="_blank" rel="noopener">Open this view on nextstrain.org</a></p>

  <script>
    // "@2024-01-01" asks for the dataset as it was on that date, so the tree students see does not
    // change during the course. Without it, nextstrain.org/zika shows the latest build.
    const DATASET = "https://nextstrain.org/zika@2024-01-01";
    // onlyPanels hides Nextstrain's header and footer; sidebar=closed gives the tree more room
    const FOR_FRAME = "onlyPanels&sidebar=closed";

    const frame = document.getElementById("nextstrain");
    const buttons = document.querySelectorAll("button[data-view]");

    function show(button) {
      const view = button.dataset.view;              // d = panels, c = colour by, m = x axis, l = layout
      frame.src = DATASET + "?" + view + "&" + FOR_FRAME;
      document.getElementById("address").textContent = frame.src;
      document.getElementById("open").href = DATASET + "?" + view;
      buttons.forEach(b => b.setAttribute("aria-pressed", b === button));
    }
    buttons.forEach(b => b.addEventListener("click", () => show(b)));
    show(buttons[0]);
  </script>
</body>
</html>

The rest of the address sets the view. These settings come from Auspice’s documentation:

To do this Add to the address
Fix the dataset at a date @2024-01-01 after the dataset’s name
Choose the panels d=tree or d=tree,map
Colour by a field c=region, c=country
Put divergence, not time, on the horizontal axis m=div
Change the layout l=radial, l=unrooted, l=clock
Keep only some samples f_region=Oceania
Hide the header and footer, for an iframe onlyPanels
Close the side bar or the legend sidebar=closed, legend=closed

Microreact can be framed in the same way [6]. https://microreact.org/project/zika-virus?tt=rc opens a project of 26 Zika virus genomes with a map, a rectangular tree and a timeline. It downloads about 2 MB and loads two analytics services. Microreact does not let other websites read its project data, so you can embed or link a project but not draw its tree yourself.

Draw a Nextstrain dataset yourself

The same address returns the dataset as JSON when it is asked for JSON, and Nextstrain lets other websites read it. The tree is already nested, so d3 can draw it without a parser. Here each node is placed at its date, which is the sampling date for a genome and an estimate for an ancestor, so the tree is a time tree.

Code nextstrain-d3.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>d3: a Zika virus time tree drawn from Nextstrain's data</title>
  <script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"
          integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i"
          crossorigin="anonymous"></script>
  <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 6px; font-size: 14px; color: #3b4659; }
    .controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 0 0 6px; font-size: 14px; }
    select { font: inherit; font-size: 14px; padding: 3px 6px; }
    #status { color: #172033; }
    svg { display: block; }
    .branch { fill: none; stroke: #9aa3b2; stroke-width: 1px; }
    .legend { list-style: none; padding: 0; margin: 6px 0; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; }
    .legend span { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; }
  </style>
</head>
<body>
  <h1>Zika virus time tree, drawn with d3 from Nextstrain's data</h1>
  <div class="controls">
    <label for="region">Show</label>
    <select id="region"><option value="">every region</option></select>
  </div>
  <p id="status" role="status" aria-live="polite">Loading the dataset from nextstrain.org…</p>
  <div id="tree"></div>
  <ul class="legend" id="legend"></ul>
  <p>Data: the Nextstrain Zika build as it stood on 1 January 2024, from genomes shared by research groups worldwide.</p>

  <script>
    // The same dated dataset as the embedded view. Asking for JSON returns the data behind it.
    // Accept is one of the headers a page may send without a CORS pre-flight request,
    // which matters because Nextstrain's answer to a pre-flight request lacks the CORS header.
    const DATASET = "https://nextstrain.org/zika@2024-01-01";
    const box = document.getElementById("tree");
    const status = document.getElementById("status");
    const choice = document.getElementById("region");

    // Decimal years (2016.45) to dates (2016-06-14), for the tooltips
    const asDate = y => new Date(Date.UTC(Math.floor(y), 0, 1) + (y % 1) * 365.25 * 864e5).toISOString().slice(0, 10);

    // Fetch JSON, giving up after 20 seconds rather than wait for ever when a server hangs.
    // The clock runs until the whole reply has arrived, not just its first part.
    async function getJSONWithin(url, options = {}, ms = 20000) {
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), ms);
      try {
        const r = await fetch(url, { ...options, signal: ctrl.signal });
        if (!r.ok) throw new Error("HTTP " + r.status);
        return await r.json();
      } catch (err) {
        throw err.name === "AbortError" ? new Error("no answer within " + ms / 1000 + " seconds") : err;
      } finally {
        clearTimeout(timer);
      }
    }

    async function main() {
      const data = await getJSONWithin(DATASET, { headers: { Accept: "application/json" } });

      const root = d3.hierarchy(data.tree);                    // the tree is already nested "children"
      const date = d => d.data.node_attrs.num_date.value;      // every node, including ancestors, has a date
      const region = d => d.data.node_attrs.region?.value ?? "unknown";
      const tips = root.leaves();
      const counts = d3.rollup(tips, v => v.length, region);
      const regions = [...counts.keys()].sort();
      const colour = d3.scaleOrdinal(regions, d3.schemeTableau10);

      choice.innerHTML += regions.map(r => `<option value="${r}">${r}</option>`).join("");
      document.getElementById("legend").innerHTML = regions
        .map(r => `<li><span style="background:${colour(r)}"></span>${r} (${counts.get(r)})</li>`).join("");
      status.textContent = `${tips.length} genomes, sampled from ${asDate(d3.min(tips, date)).slice(0, 4)} to ` +
        `${asDate(d3.max(tips, date)).slice(0, 4)}. The root is dated to ${date(root).toFixed(1)}. ` +
        `Dataset built on ${data.meta.updated}.`;

      function draw() {
        const W = Math.max(300, Math.min(780, box.clientWidth)), H = 430;
        const M = { top: 6, right: 12, bottom: 30, left: 6 };
        d3.cluster().size([H - M.top - M.bottom, 1]).separation(() => 1)(root);   // vertical positions only
        const x = d3.scaleLinear(d3.extent(root.descendants(), date), [0, W - M.left - M.right]);
        const picked = choice.value;

        box.innerHTML = "";
        const svg = d3.select(box).append("svg").attr("width", W).attr("height", H).attr("role", "img")
          .attr("aria-label", `Time tree of ${tips.length} Zika virus genomes; the counts by region are listed below the tree`);
        const g = svg.append("g").attr("transform", `translate(${M.left},${M.top})`);
        // x is the date of each node, so branch lengths are in years
        g.selectAll(".branch").data(root.links()).join("path").attr("class", "branch")
          .attr("d", l => `M${x(date(l.source))},${l.source.x}V${l.target.x}H${x(date(l.target))}`);
        // When one region is chosen, grey out the rest and draw that region on top: no need to tell colours apart
        const order = picked ? tips.slice().sort((a, b) => (region(a) === picked) - (region(b) === picked)) : tips;
        g.selectAll("circle").data(order).join("circle")
          .attr("cx", d => x(date(d))).attr("cy", d => d.x)
          .attr("r", d => (picked && region(d) === picked ? 3.5 : 2.5))
          .attr("fill", d => (!picked || region(d) === picked ? colour(region(d)) : "#d5dae3"))
          .append("title").text(d => `${d.data.name}\n${d.data.node_attrs.country?.value ?? ""}, ${region(d)}\n${asDate(date(d))}`);
        g.append("g").attr("transform", `translate(0,${H - M.top - M.bottom + 6})`)
          .call(d3.axisBottom(x).ticks(Math.max(3, Math.floor(W / 90))).tickFormat(d3.format("d")));
      }

      choice.addEventListener("change", draw);
      window.addEventListener("resize", draw);
      draw();
    }

    main().catch(err => { status.textContent = "Could not load the dataset from nextstrain.org (" + err.message + "). Try again later."; });
  </script>
</body>
</html>

What the code does:

  • The request sends one header, Accept: application/json, which browsers allow without asking the server first. Any other header makes the browser send a pre-flight request, and Nextstrain’s reply to that lacks the header that lets other websites read it, so the request fails.
  • The data. tree holds nested children. Each node’s node_attrs has num_date (the date as a decimal year), div (divergence from the root), country and region. Nextstrain also estimates dates for the ancestors.
  • Colour. Seven colours are hard to tell apart, so the menu picks out one region and greys out the rest. The counts for each region are written under the tree.

Use Phylocanvas.gl for very large trees

SVG makes every branch and name a separate element on the page. For trees with thousands of tips, Phylocanvas.gl draws on a WebGL canvas instead, with five layouts and zooming. Microreact’s tree panel is drawn with it. This example draws the 972 Zika virus genomes from the same dataset.

This example downloads Phylocanvas.gl and the Nextstrain dataset (about 0.35 MB in all), and needs a browser that can draw WebGL.

Code phylocanvas-large.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Phylocanvas.gl: 972 Zika virus genomes on a WebGL canvas</title>
  <!-- One self-contained bundle (it includes deck.gl). It defines the global "phylocanvas". -->
  <script src="https://cdn.jsdelivr.net/npm/@phylocanvas/phylocanvas.gl@1.64.1/dist/bundle.min.js"
          integrity="sha384-v3Yue+7espDjAVbW0OWPsjQFnzPWn0CHxnb4UT8mam6xUrIGUqscnYf16G+W1K7A"
          crossorigin="anonymous"></script>
  <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 6px; font-size: 14px; color: #3b4659; }
    .controls { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 6px; }
    button { font: inherit; font-size: 14px; padding: 3px 9px; }
    button[aria-pressed="true"] { background: #1f4e9c; color: #fff; border: 1px solid #1f4e9c; border-radius: 3px; }
    #status { color: #172033; }
    #tree { border: 1px solid #c3cbd8; }
    .legend { list-style: none; padding: 0; margin: 6px 0 0; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; }
    .legend span { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; }
  </style>
</head>
<body>
  <h1>972 Zika virus genomes, drawn by Phylocanvas.gl</h1>
  <p>Scroll over the tree to zoom and drag to move it. Branch lengths are in years.</p>
  <div class="controls" role="group" aria-label="Layout">
    <button type="button" data-type="rc" aria-pressed="true">Rectangular</button>
    <button type="button" data-type="cr">Circular</button>
    <button type="button" data-type="rd">Radial</button>
  </div>
  <p id="status" role="status" aria-live="polite">Loading the dataset from nextstrain.org…</p>
  <div id="tree" role="img" aria-label="Time tree of 972 Zika virus genomes, coloured by region; the regions and their counts are listed below"></div>
  <ul class="legend" id="legend"></ul>

  <script>
    const DATASET = "https://nextstrain.org/zika@2024-01-01";     // the dated dataset used in the other examples
    const COLOURS = ["#4e79a7", "#f28e2c", "#e15759", "#76b7b2", "#59a14f", "#edc949", "#af7aa1", "#ff9da7"];
    const box = document.getElementById("tree");
    const status = document.getElementById("status");

    // Nextstrain's nested JSON to Newick. Each branch length is the number of years between a node and its
    // parent, so this is a time tree. Tip names become IDs, which must be unique, and a few repeat here.
    function toNewick(node, parentDate, tips) {
      const date = node.node_attrs.num_date.value;
      const length = Math.max(0, date - parentDate).toFixed(5);
      if (node.children) return "(" + node.children.map(c => toNewick(c, date, tips)).join(",") + "):" + length;
      let id = node.name.replace(/[^A-Za-z0-9_.-]/g, "_");     // keep characters that mean something in Newick out
      while (tips.has(id)) id += "_";
      tips.set(id, node.node_attrs.region?.value ?? "unknown");
      return id + ":" + length;
    }

    // Fetch JSON, giving up after 20 seconds rather than wait for ever when a server hangs.
    // The clock runs until the whole reply has arrived, not just its first part.
    async function getJSONWithin(url, options = {}, ms = 20000) {
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), ms);
      try {
        const r = await fetch(url, { ...options, signal: ctrl.signal });
        if (!r.ok) throw new Error("HTTP " + r.status);
        return await r.json();
      } catch (err) {
        throw err.name === "AbortError" ? new Error("no answer within " + ms / 1000 + " seconds") : err;
      } finally {
        clearTimeout(timer);
      }
    }

    async function main() {
      const data = await getJSONWithin(DATASET, { headers: { Accept: "application/json" } });

      const tips = new Map();                                  // tip ID -> region
      const newick = toNewick(data.tree, data.tree.node_attrs.num_date.value, tips) + ";";
      const regions = [...new Set(tips.values())].sort();
      const colourOf = r => COLOURS[regions.indexOf(r) % COLOURS.length];
      const styles = {};
      for (const [id, r] of tips) styles[id] = { fillColour: colourOf(r) };   // one style per tip ID

      const tree = new phylocanvas.PhylocanvasGL(box, {
        source: newick,
        size: { width: Math.max(300, Math.min(780, box.clientWidth)), height: 400 },
        type: phylocanvas.TreeTypes.Rectangular,
        interactive: true,              // scroll to zoom, drag to pan, click a tip to select it
        nodeSize: 6,
        showLeafLabels: false,          // 972 names do not fit; zoom in and turn them on if you need them
        padding: 12,
        styles
      }, [ phylocanvas.plugins.scalebar ]);

      document.querySelectorAll("button[data-type]").forEach(b => b.addEventListener("click", () => {
        tree.setTreeType(b.dataset.type);                // also resets the zoom
        document.querySelectorAll("button[data-type]").forEach(x => x.setAttribute("aria-pressed", x === b));
      }));
      window.addEventListener("resize", () => tree.resize(Math.max(300, Math.min(780, box.clientWidth)), 400));

      const count = r => [...tips.values()].filter(v => v === r).length;
      document.getElementById("legend").innerHTML = regions
        .map(r => `<li><span style="background:${colourOf(r)}"></span>${r} (${count(r)})</li>`).join("");
      status.textContent = `${tips.size} genomes from the Nextstrain Zika dataset of 1 January 2024.`;
    }

    // Phylocanvas.gl needs WebGL. Without it (some locked-down or remote desktops) the box would stay blank.
    const test = document.createElement("canvas");
    if (test.getContext("webgl2") || test.getContext("webgl")) {
      main().catch(err => { status.textContent = "Could not draw the tree (" + err.message + ")."; });
    } else {
      status.textContent = "This browser cannot draw WebGL graphics, which Phylocanvas.gl needs. Try another browser or computer.";
    }
  </script>
</body>
</html>

Points to note:

  • new phylocanvas.PhylocanvasGL(element, settings, plugins) draws the tree. It takes Newick text as source, so toNewick() converts Nextstrain’s JSON, with branch lengths in years.
  • Tip names are IDs. They must be unique (a few repeat in this dataset), and styles gives each ID its own colour.
  • setTreeType() switches between the rectangular, circular and radial layouts. interactive: true turns on zooming with the mouse wheel and moving by dragging, so the canvas takes over scrolling while the pointer is on it.
  • Without WebGL, as on some locked-down or remote desktops, the canvas stays blank apart from the Phylocanvas logo, with one error in the console. The example checks first and says so instead.

Things that catch people out

  • Support values belong to branches. Newick writes them as node labels, so any code that reroots a tree has to move them with their branches. Test a library with made-up values before you trust it.
  • Scale bars. Measure a branch of known length against the scale bar before you publish a tree.
  • Roots that mean nothing. Newick nests every tree in brackets, so a file always looks rooted, even when the method that made it cannot place a root. The TreeBASE file marks this tree as rooted ([&R]). Root a tree on an outgroup before students read clades from it, or tell them the root means nothing.
  • Names. Names with spaces or punctuation must be in single quotes. The 20-line parser does not handle quotes; phylotree.js does. In unquoted names, underscores stand for spaces.
  • Phones. Species names can take half the width of a phone screen. The examples size the tree to the page, and phylotree.js shrinks the font when the names do not fit.
  • Screen readers and keyboards. phylotree.js puts the names on the page as text, but its menu and tips cannot be reached from the keyboard. Phylocanvas.gl draws on a canvas, so a screen reader finds nothing to read. Give a text version and keyboard controls, as the d3 examples do. See accessibility.
  • Datasets change. Nextstrain’s live builds change as genomes arrive, so teach from a dated address. Microreact projects belong to their owners, who can change or delete them.
  • Reading files from other websites. TreeBASE does not allow it, so copy the Newick text into your page or host the file next to it. The Open Tree of Life API, Nextstrain and GitHub Pages all allow it.
  • Versions. Each example names an exact version of its library. phylotree.js released versions 2.2 to 2.6 between December 2025 and March 2026; when you update, check the scale bar and rerooting again.

Licences and credits

phylotree.js, Phylocanvas.gl and underscore are under the MIT licence, and d3 under the ISC licence. If you publish teaching material built on them, cite phylotree.js [2] and d3 [4]; for Nextstrain and Microreact, cite their papers [5, 6] and the groups who shared the data, whom Nextstrain names with each dataset. The primate tree comes from Faircloth and colleagues [1] and is deposited in TreeBASE as study S12156 (trees for that study). TreeBASE’s files carry an acknowledgement but name no licence, so the examples give the full citation with the tree. The Open Tree of Life holds the same tree as study pg_2579 [3].

References

  1. Faircloth BC, McCormack JE, Crawford NG, Harvey MG, Brumfield RT, Glenn TC (2012). Ultraconserved elements anchor thousands of genetic markers spanning multiple evolutionary timescales. Systematic Biology 61(5): 717–726. doi:10.1093/sysbio/sys004
  2. Shank SD, Weaver S, Kosakovsky Pond SL (2018). phylotree.js: a JavaScript library for application development and interactive data visualization in phylogenetics. BMC Bioinformatics 19: 276. doi:10.1186/s12859-018-2283-2
  3. Hinchliff CE, Smith SA, Allman JF, et al. (2015). Synthesis of phylogeny and taxonomy into a comprehensive tree of life. PNAS 112(41): 12764–12769. doi:10.1073/pnas.1423041112
  4. Bostock M, Ogievetsky V, Heer J (2011). D³: data-driven documents. IEEE Transactions on Visualization and Computer Graphics 17(12): 2301–2309. doi:10.1109/TVCG.2011.185
  5. Hadfield J, Megill C, Bell SM, et al. (2018). Nextstrain: real-time tracking of pathogen evolution. Bioinformatics 34(23): 4121–4123. doi:10.1093/bioinformatics/bty407
  6. Argimón S, Abudahab K, Goater RJE, et al. (2016). Microreact: visualizing and sharing data for genomic epidemiology and phylogeography. Microbial Genomics 2(11): e000093. doi:10.1099/mgen.0.000093