Showing sequences and alignments
Show a multiple alignment in a teaching page, with the position you are teaching marked and a text version that does not depend on colour. Nightingale suits most pages. Use react-msaview for conservation tracks, plain SVG when the page must work without a library, and SeqViz for a single DNA sequence.
Which viewer to use
| Feature | Nightingale | react-msaview | Plain SVG | SeqViz |
|---|---|---|---|---|
| What it is | Web components for protein data, from EMBL-EBI | The JBrowse project’s alignment viewer, as one script that includes React | About 100 lines of your own JavaScript | A viewer for one DNA sequence with its annotations and translation |
| Download | Five modules, 341 kB (115 kB compressed) | 1.3 MB (425 kB compressed) | Nothing | 0.63 MB (166 kB compressed) |
| Best for | Most alignment pages: a highlighted column, zooming, and clicks your page can answer | Conservation tracks, your own data for each column, trees, and showing only the differences | Pages that must work offline, from the keyboard or with a screen reader | An exon, a gene or a plasmid, with codons and single bases marked |
| Limits | Drawn on a canvas, with no keyboard control, so it needs a text version | Heavier, and four major versions between August and October 2026 | You write it and look after it | One sequence, not an alignment |
| Licence | MIT | MIT | Yours | MIT |
The examples below use β-globin, the protein coded by the HBB gene, and the position that changes in sickle-cell disease, so that you can compare the viewers on the same data. All of them were tested on 15 October 2026, with Nightingale 5.11.0, react-msaview 8.4.1 and SeqViz 3.10.25. Older alignment libraries, such as BioJS MSAViewer [1] and react-msa-viewer, have had no new release since 2016 and 2018.
Mark the sickle-cell position in an alignment
Nightingale is a set of web components for protein data, used on the UniProt and InterPro websites [2]. Each component is an HTML element: here a navigation bar, a ruler with the human sequence, and the alignment. A nightingale-manager element around them keeps them in step, so zooming one zooms all three. This example aligns β-globin from eight species and highlights column 7, the sickle-cell position.
Eight reviewed UniProt entries (release 2026_03), aligned with FAMSA [3]. The read-out under the alignment is the text version of the highlighted column.
Code nightingale-msa.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nightingale: β-globin in eight species, with the sickle-cell position highlighted</title>
<!-- Nightingale 5.11.0 comes as ES modules from jsDelivr. Every component imports the shared core
by name, so the import map says where that name lives. The integrity list holds a hash for
each module, so a changed file does not run. -->
<script type="importmap">
{
"imports": {
"@nightingale-elements/nightingale-new-core": "https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-new-core@5.11.0/dist/index.js"
},
"integrity": {
"https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-new-core@5.11.0/dist/index.js": "sha384-jr6Yu/v5IIaoD6Hk4TG4tkNnrizDDewDyAifRF1S3NjF1t/xg+5d1188iJG34hb8",
"https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-msa@5.11.0/dist/index.js": "sha384-X8Vi+Bbnhn0Y12X7rjSiB+39BnEFcAXzskxm3+eisUZHM5oeG0jYJknDGi5hUL/k",
"https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-navigation@5.11.0/dist/index.js": "sha384-KbFSF43wJ9w9jCy+xxBOqGPF6sF6gW6rJoDfn2y0mlYZZF/FhARWV1iKTgBufI5I",
"https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-manager@5.11.0/dist/index.js": "sha384-D2XIdvPlEPljf0/P45qpN7TAK9ncW5Ums+2gS4qyekXQfS9L3rHa1MI3BR49zsS0",
"https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-sequence@5.11.0/dist/index.js": "sha384-sC1duoOfLF0jk/lxCp7kE3kYefySpWS21ptmFrrARGWAB2FZjpSweWhRLaeM3JJl"
}
}
</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 8px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 0; }
.controls form, .controls span { display: inline-flex; align-items: center; gap: 6px; }
button, input, select { font: inherit; font-size: 14px; padding: 4px 9px; }
input[type="number"] { width: 4.5em; }
#readout { font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; color: #3b4659; white-space: pre-line; min-height: 5.8em; margin: 6px 0 8px; }
nightingale-manager { display: block; }
</style>
</head>
<body>
<h1>β-globin in eight species: the sickle-cell position</h1>
<p>Column 7 is residue 7 of human β-globin in UniProt, the glutamate that older papers call Glu6.
Sickle-cell haemoglobin has valine here. Click a letter, or use the controls below.</p>
<!-- The manager keeps the tracks in step: an attribute named in reflected-attributes,
set on the manager, is passed to every track inside it. -->
<nightingale-manager id="manager" reflected-attributes="display-start,display-end,highlight">
<nightingale-navigation length="147" height="40" display-start="1" display-end="40"
margin-left="100" ruler-padding="0" min-width="300" show-highlight highlight-color="#e8590c88"></nightingale-navigation>
<!-- The human sequence as a ruler: its numbers are human UniProt residue numbers -->
<nightingale-sequence id="ruler" length="147" height="30" display-start="1" display-end="40"
margin-left="100" min-width="300" highlight-color="#e8590c88"></nightingale-sequence>
<nightingale-msa id="msa" length="147" height="170" tile-height="20" label-width="90" min-width="300"
display-start="1" display-end="40" color-scheme="clustal2" highlight-color="#e8590c"></nightingale-msa>
</nightingale-manager>
<p id="readout" role="status" aria-live="polite">Loading the alignment…</p>
<div class="controls">
<button type="button" id="sickle">Sickle-cell position</button>
<button type="button" id="whole">Whole alignment</button>
<form id="goto">
<label for="column">Column</label>
<input id="column" type="number" min="1" max="147" value="7">
<button type="submit">Show</button>
</form>
<span>
<label for="scheme">Colours</label>
<select id="scheme">
<option value="clustal2">Clustal: type of residue</option>
<option value="zappo">Zappo: chemistry</option>
<option value="conservation">Share of rows with this residue</option>
</select>
</span>
</div>
<script>
// If the modules cannot load (no network, or a hash that does not match), say so instead of waiting for ever
setTimeout(() => {
if (!customElements.get("nightingale-msa")) {
document.getElementById("readout").textContent = "Nightingale did not load. Check the connection and reload the page.";
}
}, 15000);
</script>
<script type="module">
import "https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-manager@5.11.0/dist/index.js";
import "https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-navigation@5.11.0/dist/index.js";
import "https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-sequence@5.11.0/dist/index.js";
import "https://cdn.jsdelivr.net/npm/@nightingale-elements/nightingale-msa@5.11.0/dist/index.js";
// Eight reviewed UniProt entries (release 2026_03): P68871 human, P68873 chimpanzee, P02088 mouse,
// P02091 rat, P02062 horse, P60524 dog, P02070 cow and P02112 chicken, aligned with FAMSA.
// data/hbb-beta-globins.aln.fasta holds the same alignment under UniProt's entry names.
const FASTA = `>Human
MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH
>Chimpanzee
MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH
>Mouse
MVHLTDAEKAAVSCLWGKVNSDEVGGEALGRLLVVYPWTQRYFDSFGDLSSASAIMGNAKVKAHGKKVITAFNDGLNHLDSLKGTFASLSELHCDKLHVDPENFRLLGNMIVIVLGHHLGKDFTPAAQAAFQKVVAGVATALAHKYH
>Rat
MVHLTDAEKAAVNGLWGKVNPDDVGGEALGRLLVVYPWTQRYFDSFGDLSSASAIMGNPKVKAHGKKVINAFNDGLKHLDNLKGTFAHLSELHCDKLHVDPENFRLLGNMIVIVLGHHLGKEFTPCAQAAFQKVVAGVASALAHKYH
>Horse
-VQLSGEEKAAVLALWDKVNEEEVGGEALGRLLVVYPWTQRFFDSFGDLSNPGAVMGNPKVKAHGKKVLHSFGEGVHHLDNLKGTFAALSELHCDKLHVDPENFRLLGNVLVVVLARHFGKDFTPELQASYQKVVAGVANALAHKYH
>Dog
-VHLTAEEKSLVSGLWGKVNVDEVGGEALGRLLIVYPWTQRFFDSFGDLSTPDAVMSNAKVKAHGKKVLNSFSDGLKNLDNLKGTFAKLSELHCDKLHVDPENFKLLGNVLVCVLAHHFGKEFTPQVQAAYQKVVAGVANALAHKYH
>Cow
--MLTAEEKAAVTAFWGKVKVDEVGGEALGRLLVVYPWTQRFFESFGDLSTADAVMNNPKVKAHGKKVLDSFSNGMKHLDDLKGTFAALSELHCDKLHVDPENFKLLGNVLVVVLARNFGKEFTPVLQADFQKVVAGVANALAHRYH
>Chicken
MVHWTAEEKQLITGLWGKVNVAECGAEALARLLIVYPWTQRFFASFGNLSSPTAILGNPMVRAHGKKVLTSFGDAVKNLDNIKNTFSQLSELHCDKLHVDPENFRLLGDILIIVLAAHFSKDFTPECQAAWQKLVRVVAHALARKYH`;
const rows = FASTA.trim().split(/^>/m).filter(Boolean).map(block => {
const [name, ...lines] = block.split("\n");
return { name: name.trim(), sequence: lines.join("").trim() };
});
const LENGTH = rows[0].sequence.length; // 147 columns
const AA = { A: "Ala", R: "Arg", N: "Asn", D: "Asp", C: "Cys", Q: "Gln", E: "Glu", G: "Gly", H: "His", I: "Ile",
L: "Leu", K: "Lys", M: "Met", F: "Phe", P: "Pro", S: "Ser", T: "Thr", W: "Trp", Y: "Tyr", V: "Val" };
const manager = document.getElementById("manager");
const msa = document.getElementById("msa");
const readout = document.getElementById("readout");
// The residue number of a column (counting from 1) in one aligned sequence, or null for a gap.
// Each sequence counts its own residues, so the same column can hold residue 7, 6 or 5.
const residueNumber = (seq, column) =>
seq[column - 1] === "-" ? null : seq.slice(0, column).replace(/-/g, "").length;
// A text version of one column, for everyone and for screen readers
function describe(column) {
const human = residueNumber(rows[0].sequence, column);
// UniProt counts the first methionine as residue 1; the older numbering starts after it
const where = human === null ? "a gap in human"
: human === 1 ? "human residue 1, the methionine removed from the mature protein"
: "human residue " + human + ", " + AA[rows[0].sequence[column - 1]] + (human - 1) + " in the older numbering";
// the commonest residue in the column, and how many species have it
const counts = new Map();
for (const r of rows) {
const aa = r.sequence[column - 1];
if (aa !== "-") counts.set(aa, (counts.get(aa) || 0) + 1);
}
const [top, n] = [...counts].sort((a, b) => b[1] - a[1])[0] || ["-", 0];
const each = rows.map(r => {
const k = residueNumber(r.sequence, column);
return r.name.toLowerCase() + " " + (k === null ? "gap" : r.sequence[column - 1] + k);
});
return "Column " + column + " (" + where + "): " +
(n === rows.length ? "all " : n + " of ") + rows.length + " species have " + top + " (" + AA[top] + ").\n" +
"In each species, with its own residue number: " + each.join(", ") + ".";
}
// Show about 18 pixels per column, so the letters stay readable on a phone
const fit = () => Math.max(8, Math.min(LENGTH, Math.floor((manager.clientWidth - 100) / 18)));
function showColumns(start, end) {
manager.setAttribute("display-start", String(start));
manager.setAttribute("display-end", String(end));
}
function select(column, clicked) {
column = Math.min(LENGTH, Math.max(1, column));
document.getElementById("column").value = column;
manager.setAttribute("highlight", column + ":" + column); // start:end, counting from 1
const start = +manager.getAttribute("display-start"), end = +manager.getAttribute("display-end");
if (column < start || column > end) { // move the view if the column is off screen
const s = Math.max(1, Math.min(column - Math.floor(fit() / 2), LENGTH - fit() + 1));
showColumns(s, s + fit() - 1);
}
readout.textContent = (clicked || "") + describe(column);
}
await customElements.whenDefined("nightingale-msa");
await customElements.whenDefined("nightingale-sequence");
msa.data = rows; // [{ name, sequence }], gaps as "-"
document.getElementById("ruler").sequence = rows[0].sequence; // human has no gaps in this alignment
showColumns(1, fit());
select(7);
// position is the column and i the row, both counting from 0
msa.addEventListener("onResidueClick", e => {
const { position, i } = e.detail;
const row = rows[i], column = position + 1, aa = row.sequence[position];
const own = aa === "-" ? "a gap" : aa + " (" + AA[aa] + "), residue " + residueNumber(row.sequence, column) + " of the " + row.name.toLowerCase() + " sequence";
select(column, row.name + ": " + own + ".\n");
});
document.getElementById("sickle").addEventListener("click", () => { showColumns(1, fit()); select(7); });
document.getElementById("whole").addEventListener("click", () => showColumns(1, LENGTH));
document.getElementById("goto").addEventListener("submit", e => {
e.preventDefault();
select(+document.getElementById("column").value || 7);
});
document.getElementById("scheme").addEventListener("change", e => msa.setAttribute("color-scheme", e.target.value));
</script>
</body>
</html>How it works:
- Loading. Nightingale 5 is published as ES modules. Each component imports the shared core package by name, so a short import map tells the browser where that name lives, and all the components share one copy of it. The map’s
integritylist holds a hash for each module. Chromium refused to run a module when we changed its hash; we did not test other browsers. - Data. Set
msa.datato a list of{ name, sequence }objects, with gaps written as-. The alignment is written into the page, so the example works as a single downloaded file. - Highlight and zoom. Set
highlight="7:7"(first and last column, counting from 1),display-startanddisplay-endon the manager. It passes every attribute named in itsreflected-attributesto all the tracks inside it. - Clicks. The alignment sends an
onResidueClickevent. Itsdetailholdsposition(the column) andi(the row), both counting from 0. - Text version. The read-out lists every species’ residue in the chosen column, with its number in that species’ own sequence, in an element with
aria-live="polite"so that screen readers announce it. The buttons and the column box do everything the mouse does, apart from dragging the view. - Small screens. The page works out how many columns fit at about 18 pixels each: 43 in this guide, 11 on a phone.
min-width="300"on each track avoids errors when the page first draws.
The colour menu makes a point worth teaching: colours are a choice. Clustal colours residues by type, Zappo by chemistry, and the third option shades each residue by how many rows share it. Name the scheme in every caption and handout.
Get residue numbers right
The sickle-cell glutamate has two numbers, and both are right. UniProt counts along the sequence it stores, which for human β-globin starts with the methionine coded by the start codon. That methionine is removed from the finished protein: UniProt records it as an “Initiator methionine” feature and the mature chain as residues 2 to 147. Older papers number the mature chain, so the glutamate is Glu6 there, and in protein structures such as PDB entry 4HHB. It is residue 7 in UniProt and in variant names that follow the HGVS rules, such as p.Glu7Val.
| Where you meet it | Number of the sickle-cell glutamate |
|---|---|
UniProt P68871, the AlphaFold model of P68871, and ClinVar’s name for the variant, NM_000518.5(HBB):c.20A>T (p.Glu7Val) |
7 |
| The older haemoglobin numbering (HbS is “β6 Glu→Val”) and PDB entry 4HHB, chain B | 6 |
| The gene’s coding sequence: bases c.19 to c.21 (GAG); the sickle-cell change is c.20A>T | codon 7 |
| UniProt’s horse and dog entries, which describe the mature protein | 6 |
| UniProt’s cow entry, whose sequence starts two columns later in this alignment | 5 |
| This alignment | column 7 |
An alignment column is not a residue number either: add a sequence with one extra residue at the start, and every column number moves. So say whose numbering you use every time you print a number, and work out residue numbers from each sequence, not from the column. In code, count the residues in the row up to the column, skipping gaps:
// The residue number of an alignment column (counting from 1) in one aligned sequence, or null for a gap
const residueNumber = (aligned, column) =>
aligned[column - 1] === "-" ? null : aligned.slice(0, column).replace(/-/g, "").length;
residueNumber("-VQLSGEEK", 7); // 6: the horse glutamate in column 7
The same offset applies to every residue of human β-globin. The two histidines that hold the haem are His63 and His92 in PDB entry 4HHB, and residues 64 and 93 in UniProt.
Fetch sequences from UniProt
UniProt’s REST API answers requests from any website (it sends Access-Control-Allow-Origin: *), so a page can look up entries while students work [4]. This example fetches one entry and lists everything UniProt records at the residue you choose: the sickle-cell variant at residue 7, for instance, or the histidine at residue 64 that holds the haem.
Live data from UniProt. The page names the UniProt release that answered.
Code uniprot-residue.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>UniProt REST API: what UniProt records about one residue</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; }
h2 { font-size: 0.95rem; margin: 10px 0 4px; }
p { margin: 0 0 6px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 0 0 8px; }
.controls span { display: inline-flex; align-items: center; gap: 6px; }
button, input { font: inherit; font-size: 14px; padding: 4px 9px; }
#acc { width: 7.5em; }
#pos { width: 5em; }
#status { min-height: 1.5em; font-weight: 600; }
#source { font: 12px/1.45 ui-monospace, Menlo, Consolas, monospace; color: #3b4659; overflow-wrap: anywhere; margin: 4px 0 0; }
table { border-collapse: collapse; font-size: 14px; margin: 2px 0 8px; width: 100%; }
caption { text-align: left; font-weight: 600; padding: 0 0 4px; }
th, td { text-align: left; vertical-align: top; padding: 3px 8px; border-bottom: 1px solid #dde2ea; }
th { border-bottom: 2px solid #c3cbd8; }
.seq { font: 14px/1.3 ui-monospace, Menlo, Consolas, monospace; margin: 0; }
.block { display: inline-block; margin: 0 10px 6px 0; }
.block small { display: block; font-size: 11px; color: #5b6576; }
mark { background: #ffc078; outline: 2px solid #e8590c; }
</style>
</head>
<body>
<h1>What UniProt records about one residue</h1>
<p>The page asks the UniProt REST API for one entry, then lists every feature that covers the residue you choose.</p>
<form id="lookup" class="controls">
<span><label for="acc">UniProt accession</label><input id="acc" value="P68871" autocomplete="off" spellcheck="false"></span>
<span><label for="pos">Residue</label><input id="pos" type="number" min="1" value="7"></span>
<button type="submit">Look up</button>
</form>
<div class="controls" role="group" aria-label="Examples">
<button type="button" data-acc="P68871" data-pos="7">Human β-globin, residue 7</button>
<button type="button" data-acc="P68871" data-pos="64">Human β-globin, residue 64</button>
<button type="button" data-acc="P02062" data-pos="63">Horse β-globin, residue 63</button>
</div>
<p id="status" role="status" aria-live="polite"></p>
<div id="result" hidden>
<h2 id="entry"></h2>
<p id="residue"></p>
<table>
<caption id="caption"></caption>
<thead><tr><th scope="col">Feature</th><th scope="col">Residues</th><th scope="col">Details</th></tr></thead>
<tbody id="features"></tbody>
</table>
<h2>Sequence, in blocks of ten</h2>
<p class="seq" id="sequence"></p>
<p id="source"></p>
</div>
<script>
const AA = { A: "Ala", R: "Arg", N: "Asn", D: "Asp", C: "Cys", Q: "Gln", E: "Glu", G: "Gly", H: "His", I: "Ile",
L: "Leu", K: "Lys", M: "Met", F: "Phe", P: "Pro", S: "Ser", T: "Thr", W: "Trp", Y: "Tyr", V: "Val" };
// UniProt's documented pattern for accessions, such as P68871 or A0A024R161
const ACCESSION = /^([OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9]([A-Z][A-Z0-9]{2}[0-9]){1,2})$/;
// Ask only for the fields the page shows: about 106 kB for P68871, instead of 342 kB for the whole entry
const FIELDS = "accession,id,protein_name,organism_name,sequence," +
"ft_init_met,ft_chain,ft_domain,ft_binding,ft_act_site,ft_mod_res,ft_variant,ft_helix,ft_strand,ft_turn";
const $ = id => document.getElementById(id);
const status = text => { $("status").textContent = text; };
const fail = text => { $("result").hidden = true; status(text); }; // hide the last answer, so it is not mistaken for this one
// Fetch JSON with a time limit and one retry if the service is busy (429) or failing (5xx)
async function getJSON(url, tries = 2) {
for (let attempt = 1; ; attempt++) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 20000);
let r, body;
try {
r = await fetch(url, { signal: ctrl.signal });
body = await r.json().catch(() => null);
if (r.ok) return { body, release: r.headers.get("X-UniProt-Release") };
} catch (err) { // no answer in time, or none at all
if (attempt >= tries) throw err.name === "AbortError" ? new Error("no answer within 20 seconds") : err;
} finally {
clearTimeout(timer);
}
// Retry only when UniProt is busy or failing: other answers, such as 400 for a bad accession, will not change
if (r && !r.ok && (attempt >= tries || !(r.status === 429 || r.status >= 500))) {
throw new Error(body && body.messages ? body.messages.join(" ") : "HTTP " + r.status);
}
await new Promise(res => setTimeout(res, 2000));
}
}
function describe(f) {
if (f.alternativeSequence && f.alternativeSequence.originalSequence) { // a variant: E → V
const a = f.alternativeSequence;
const alt = a.alternativeSequences && a.alternativeSequences.length ? a.alternativeSequences.join(" or ") : "missing";
return a.originalSequence + " → " + alt + ": " + (f.description || "");
}
return [f.ligand && f.ligand.name, f.description].filter(Boolean).join("; ");
}
function row(cells) {
const tr = document.createElement("tr");
for (const text of cells) tr.appendChild(document.createElement("td")).textContent = text;
return tr;
}
// The sequence in blocks of ten, each labelled with the number of its first residue
function showSequence(seq, pos) {
const box = $("sequence");
box.replaceChildren();
for (let start = 0; start < seq.length; start += 10) {
const block = box.appendChild(document.createElement("span"));
block.className = "block";
block.appendChild(document.createElement("small")).textContent = start + 1;
const letters = seq.slice(start, start + 10);
const i = pos - 1 - start; // where the chosen residue falls in this block
if (i >= 0 && i < letters.length) {
block.append(letters.slice(0, i));
block.appendChild(document.createElement("mark")).textContent = letters[i];
block.append(letters.slice(i + 1));
} else {
block.append(letters);
}
}
}
async function lookUp(acc, pos) {
acc = acc.trim().toUpperCase();
if (!ACCESSION.test(acc)) { fail("“" + acc + "” is not a UniProt accession. Accessions look like P68871."); return; }
const url = "https://rest.uniprot.org/uniprotkb/" + acc + "?format=json&fields=" + FIELDS;
status("Asking UniProt for " + acc + "…");
let reply;
try {
reply = await getJSON(url);
} catch (err) {
fail("UniProt did not answer (" + err.message + "). Try again later.");
return;
}
const e = reply.body;
if (e.entryType === "Inactive") { // merged, demerged or deleted entries have no sequence
fail(acc + " is no longer an active UniProt entry (" + e.inactiveReason.inactiveReasonType.toLowerCase() + ")" +
(e.inactiveReason.mergeDemergeTo ? ": try " + e.inactiveReason.mergeDemergeTo.join(", ") : "") + ".");
return;
}
const seq = e.sequence.value;
if (!(pos >= 1 && pos <= seq.length)) { fail(acc + " has " + seq.length + " residues. Choose a residue from 1 to " + seq.length + "."); return; }
const name = e.proteinDescription.recommendedName ? e.proteinDescription.recommendedName.fullName.value : "";
$("entry").textContent = e.primaryAccession + " (" + e.uniProtkbId + "): " + name + ", " + e.organism.scientificName + ", " + seq.length + " residues";
// Feature positions count from 1 along the stored sequence. The Chain feature says where the mature protein starts.
const at = f => f.location.start.value <= pos && pos <= f.location.end.value;
const chain = e.features.find(f => f.type === "Chain" && at(f));
const aa = seq[pos - 1];
let text = "Residue " + pos + " is " + aa + " (" + (AA[aa] || "unusual residue") + ").";
if (chain && chain.location.start.value > 1) {
text += " The mature protein runs from residue " + chain.location.start.value + ", so this is residue " +
(pos - chain.location.start.value + 1) + " of the mature protein, as older papers number it.";
}
$("residue").textContent = text;
const here = e.features.filter(at);
$("caption").textContent = here.length + " feature" + (here.length === 1 ? "" : "s") + " recorded at residue " + pos;
$("features").replaceChildren(...here.map(f => {
const s = f.location.start.value, end = f.location.end.value;
return row([f.type, s === end ? String(s) : s + "–" + end, describe(f)]);
}));
showSequence(seq, pos);
$("source").textContent = "Request: ";
const link = $("source").appendChild(document.createElement("a"));
link.href = url; link.target = "_blank"; link.rel = "noopener"; link.textContent = url;
$("result").hidden = false;
// The release comes from a response header that UniProt lets other websites read
status("Found " + e.primaryAccession + " in UniProt release " + (reply.release || "(not stated)") + ".");
}
$("lookup").addEventListener("submit", ev => {
ev.preventDefault();
lookUp($("acc").value, Number($("pos").value));
});
document.querySelectorAll("button[data-acc]").forEach(b => b.addEventListener("click", () => {
$("acc").value = b.dataset.acc;
$("pos").value = b.dataset.pos;
lookUp(b.dataset.acc, Number(b.dataset.pos));
}));
lookUp("P68871", 7);
</script>
</body>
</html>Two kinds of request cover most teaching pages. The first returns one entry as JSON, the second several entries as FASTA text:
https://rest.uniprot.org/uniprotkb/P68871?format=json&fields=accession,id,sequence,ft_chain,ft_variant
https://rest.uniprot.org/uniprotkb/accessions?accessions=P68871,P02062&format=fasta
How the code works:
- One entry. Listing only the fields you need cut the reply for this entry from 342 kB to 106 kB. The example asks for 15 fields.
- Features. Each feature, such as
Natural variant,Binding siteorHelix, has alocationwhosestartandendcount from 1 along the stored sequence. TheChainfeature says where the mature protein starts, which is how the page can say “residue 6 of the mature protein”. - Several sequences. The FASTA request is how this guide’s alignment started: one request for all eight entries.
- The release. UniProt lets pages read its
X-UniProt-Releaseheader, so you can record which release students saw. Entries change between releases, so keep a copy of anything a class depends on, as the alignment examples here do. - Errors. A badly formed accession gets a 400 reply with a message. A merged or retired entry comes back with
"entryType": "Inactive"and no sequence. The page handles both, and tries once more if UniProt is busy. - Entry names. UniProt also accepts names such as
HBB_HUMAN, but answers with a redirect that drops thefieldslist, so the page asks for accessions only.
Add conservation tracks with react-msaview
react-msaview adds tracks above the alignment and can draw a tree beside it. Here it loads the same alignment from a file next to the page, marks the sickle-cell residue, and adds a track of its own: the share of species that match human in each column. The third button hides every residue that matches human, which leaves only the differences.
Code react-msaview.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>react-msaview: β-globin with a highlight, your own track and differences from human</title>
<!-- react-msaview 8.4.1: one script that includes React. It defines window.ReactMSAView. -->
<script src="https://cdn.jsdelivr.net/npm/react-msaview@8.4.1/bundle/index.js"
integrity="sha384-bkCBVqpEO6arNBRJrbLqhTHUQXYq88/gXYGYujsTIx9nM3vldu1dUTKlDhbuENXZ"
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 8px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; }
button { font: inherit; font-size: 14px; padding: 4px 9px; }
#info { font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; color: #3b4659; min-height: 1.45em; margin: 6px 0 8px; }
</style>
</head>
<body>
<h1>β-globin in react-msaview</h1>
<p>Orange marks the sickle-cell position. Blue bars show how many species match human in each column.</p>
<div id="viewer"></div>
<p id="info" aria-live="polite">Loading the alignment…</p>
<div class="controls">
<button type="button" id="zoom">Residues 1–30</button>
<button type="button" id="whole">Whole alignment</button>
<button type="button" id="diff" aria-pressed="false">Show only differences from human</button>
</div>
<script>
const info = document.getElementById("info");
// The alignment is a small file hosted next to this page (UniProt release 2026_03, aligned with FAMSA)
fetch("data/hbb-beta-globins.aln.fasta")
.then(r => { if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); })
.then(start)
.catch(err => { info.textContent = "Could not load the alignment (" + err.message + ")."; });
function start(fasta) {
if (!window.ReactMSAView) { info.textContent = "react-msaview did not load. Check the connection and reload the page."; return; }
// Your own data for each column, drawn as a bar track: the share of species identical to human
const seqs = fasta.split(/^>.*$/m).map(s => s.replace(/\s/g, "")).filter(Boolean);
const sameAsHuman = Array.from(seqs[0], (aa, col) => seqs.filter(s => s[col] === aa).length / seqs.length);
const viewer = window.ReactMSAView.mount(document.getElementById("viewer"), {
msa: fasta,
drawTree: false, // no tree file, so just a column of names
autoTreeAreaWidth: true,
colorScheme: "clustal",
// On a phone the toolbar wraps onto three lines, so leave it out: the buttons below the viewer still work
hideHeader: window.innerWidth < 600,
height: window.innerWidth < 600 ? 300 : 352, // in pixels, including the toolbar when it is shown
highlights: [
// With "row", start and end are that row's own residue numbers, counting from 1
{ row: "HBB_HUMAN", start: 7, end: 7, label: "HbS site", color: "rgba(232, 89, 12, 0.35)" }
],
columnTracks: [
{ id: "same", name: "Same as human", kind: "bar", values: sameAsHuman, max: 1, color: "#1c7ed6" }
],
// column counts from 1 along the alignment; residue counts from 1 along the clicked row, and is absent on a gap
onCellClick: cell => {
if (!cell || !cell.row) { info.textContent = cell ? "Column " + cell.column : ""; return; }
info.textContent = "Column " + cell.column + ": " + cell.row + " " +
(cell.residue ? cell.letter + cell.residue : "gap") +
(cell.column === 7 ? ", the sickle-cell position" : "");
}
});
info.textContent = "Click a residue to see its column and its number in that species.";
// update() merges new options into the running viewer. A phone has room for about 12 readable columns.
const zoomEnd = window.innerWidth < 600 ? 12 : 30;
document.getElementById("zoom").textContent = "Residues 1–" + zoomEnd;
document.getElementById("zoom").addEventListener("click", () =>
viewer.update({ region: { row: "HBB_HUMAN", start: 1, end: zoomEnd } }));
document.getElementById("whole").addEventListener("click", () =>
viewer.update({ region: { start: 1, end: seqs[0].length } }));
document.getElementById("diff").addEventListener("click", e => {
const on = e.currentTarget.getAttribute("aria-pressed") !== "true";
e.currentTarget.setAttribute("aria-pressed", String(on));
viewer.update({ relativeTo: on ? "HBB_HUMAN" : undefined }); // residues that match human become dots
});
}
</script>
</body>
</html>The parts to reuse, all described in the project’s usage guide:
- Starting it.
ReactMSAView.mount(element, options)draws the viewer and returnsupdateanddestroy.updatemerges new options into the running viewer, which is how the buttons work. - Highlights. With a
row,startandendare that row’s own residue numbers, so{ row: "HBB_HUMAN", start: 7, end: 7 }marks human residue 7 wherever the gaps fall. - Your own track.
columnTracksdraws one number for each column as a bar. The built-in conservation tracks appear below it. - Differences only.
relativeTo: "HBB_HUMAN"replaces every residue that matches human with a dot. - Clicks.
onCellClickreceives the column and the clicked row’s residue number, both counting from 1. - Phones. The toolbar wraps onto three lines on a phone, so on narrow screens the example leaves it out with
hideHeader: true, and its zoom button shows 12 residues instead of 30. Theheightoption counts the toolbar when it is shown.
The package released major versions 6, 7, 8 and 9 between 18 August and 12 October 2026. Pin the exact version, and read the changes before you move to a new one.
Draw an alignment without a library
When a page must work offline, from the keyboard or with a screen reader, draw the alignment yourself. This viewer is about 100 lines of JavaScript that build an SVG drawing, with nothing to download. Students can also open their own aligned FASTA file in it.
Files opened here are read by the browser. Nothing is uploaded.
Code svg-alignment.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>An alignment drawn as SVG, with no library</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; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 8px 0; }
button, input { font: inherit; font-size: 14px; }
button { padding: 4px 9px; }
output { font: 600 14px ui-monospace, Menlo, Consolas, monospace; min-width: 2.5em; }
.legend { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 8px; padding: 0; list-style: none; }
.legend li { padding: 0 6px; border-radius: 3px; font: 12px/1.6 ui-monospace, Menlo, Consolas, monospace; color: #000; }
.msa { display: flex; border: 1px solid #c3cbd8; border-radius: 6px; }
.labels { flex: none; padding: 42px 8px 0 6px; font: 12px/16px ui-monospace, Menlo, Consolas, monospace; white-space: nowrap; }
.scroller { overflow-x: auto; min-width: 0; }
.scroller:focus-visible { outline: 3px solid #1f4e9c; }
svg { display: block; cursor: pointer; }
svg text { font: 11px ui-monospace, Menlo, Consolas, monospace; pointer-events: none; }
#summary { margin: 8px 0 2px; font-weight: 600; }
.hint { margin: 0 0 2px; font-size: 14px; color: #3b4659; }
.cells { display: grid; grid-template-columns: repeat(auto-fill, minmax(10.5em, 1fr)); gap: 0 12px; margin: 0 0 8px; padding: 0; list-style: none; font: 13px/1.6 ui-monospace, Menlo, Consolas, monospace; }
.file { font-size: 14px; color: #3b4659; }
</style>
</head>
<body>
<h1 id="heading">β-globin in eight species, drawn as SVG with no library</h1>
<p>Colours show the type of residue, and every cell keeps its letter. The bars show how many species share each
column's commonest residue. Click a column, or use the slider below the alignment.</p>
<div class="msa">
<div class="labels" id="labels" aria-hidden="true"></div>
<div class="scroller" id="scroller" tabindex="0" role="region" aria-label="Alignment, scrolls sideways">
<svg id="svg" role="img" aria-labelledby="svg-title"><title id="svg-title">Alignment</title></svg>
</div>
</div>
<div class="controls">
<label for="col">Column</label>
<input id="col" type="range" min="1" max="147" value="7">
<output id="col-out" for="col">7</output>
<button type="button" id="sickle">Sickle-cell column</button>
</div>
<ul class="legend" id="legend" aria-label="Colour key"></ul>
<p id="summary" aria-live="polite"></p>
<p class="hint">Each sequence's residue in this column, with its number in its own sequence:</p>
<ul class="cells" id="cells"></ul>
<p class="file"><label for="file">Open your own aligned FASTA file (it stays on your computer):</label><br>
<input id="file" type="file" accept=".fa,.fasta,.fas,.faa,.aln,.txt"></p>
<script>
// Eight reviewed UniProt entries (release 2026_03), aligned with FAMSA. The same alignment, under UniProt's
// entry names, is in data/hbb-beta-globins.aln.fasta.
const FASTA = `>Human
MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH
>Chimpanzee
MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH
>Mouse
MVHLTDAEKAAVSCLWGKVNSDEVGGEALGRLLVVYPWTQRYFDSFGDLSSASAIMGNAKVKAHGKKVITAFNDGLNHLDSLKGTFASLSELHCDKLHVDPENFRLLGNMIVIVLGHHLGKDFTPAAQAAFQKVVAGVATALAHKYH
>Rat
MVHLTDAEKAAVNGLWGKVNPDDVGGEALGRLLVVYPWTQRYFDSFGDLSSASAIMGNPKVKAHGKKVINAFNDGLKHLDNLKGTFAHLSELHCDKLHVDPENFRLLGNMIVIVLGHHLGKEFTPCAQAAFQKVVAGVASALAHKYH
>Horse
-VQLSGEEKAAVLALWDKVNEEEVGGEALGRLLVVYPWTQRFFDSFGDLSNPGAVMGNPKVKAHGKKVLHSFGEGVHHLDNLKGTFAALSELHCDKLHVDPENFRLLGNVLVVVLARHFGKDFTPELQASYQKVVAGVANALAHKYH
>Dog
-VHLTAEEKSLVSGLWGKVNVDEVGGEALGRLLIVYPWTQRFFDSFGDLSTPDAVMSNAKVKAHGKKVLNSFSDGLKNLDNLKGTFAKLSELHCDKLHVDPENFKLLGNVLVCVLAHHFGKEFTPQVQAAYQKVVAGVANALAHKYH
>Cow
--MLTAEEKAAVTAFWGKVKVDEVGGEALGRLLVVYPWTQRFFESFGDLSTADAVMNNPKVKAHGKKVLDSFSNGMKHLDDLKGTFAALSELHCDKLHVDPENFKLLGNVLVVVLARNFGKEFTPVLQADFQKVVAGVANALAHRYH
>Chicken
MVHWTAEEKQLITGLWGKVNVAECGAEALARLLIVYPWTQRFFASFGNLSSPTAILGNPMVRAHGKKVLTSFGDAVKNLDNIKNTFSQLSELHCDKLHVDPENFRLLGDILIIVLAAHFSKDFTPECQAAWQKLVRVVAHALARKYH`;
// Residue classes. Six colours come from the Okabe-Ito palette; black letters keep a contrast of at least 5.4:1 on all seven.
const CLASSES = [
{ name: "hydrophobic", letters: "AILMV", colour: "#9ecae1" },
{ name: "aromatic", letters: "FWY", colour: "#56b4e9" },
{ name: "positive", letters: "KRH", colour: "#e69f00" },
{ name: "negative", letters: "DE", colour: "#d55e00" },
{ name: "polar", letters: "STNQ", colour: "#009e73" },
{ name: "glycine, proline", letters: "GP", colour: "#f0e442" },
{ name: "cysteine", letters: "C", colour: "#cc79a7" }
];
const colourOf = aa => (CLASSES.find(c => c.letters.includes(aa)) || { colour: "#ffffff" }).colour;
const W = 14, H = 16, BAR = 34, TOP = BAR + 8; // cell width and height, bar area, top of the first row
// (.labels has padding-top: 42px to match TOP)
const SVGNS = "http://www.w3.org/2000/svg";
const svg = document.getElementById("svg");
const slider = document.getElementById("col");
const scroller = document.getElementById("scroller");
let rows = [], commonest = [], cursor;
function parseFasta(text) {
return text.trim().split(/^>/m).filter(Boolean).map(block => {
const [header, ...lines] = block.split(/\r?\n/);
return { name: header.trim().split(/\s+/)[0].slice(0, 24), seq: lines.join("").replace(/\s/g, "").toUpperCase().replace(/\./g, "-") };
});
}
function el(tag, attrs, parent) {
const e = document.createElementNS(SVGNS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
return parent.appendChild(e);
}
// Residue number of a column (counting from 1) in one aligned sequence, or null for a gap
const residueNumber = (seq, column) => seq[column - 1] === "-" ? null : seq.slice(0, column).replace(/-/g, "").length;
function draw(newRows) {
rows = newRows;
const ncol = rows[0].seq.length;
// For each column, the commonest residue and how many rows carry it (gaps never count)
commonest = Array.from({ length: ncol }, (_, i) => {
const counts = new Map();
for (const r of rows) if (r.seq[i] !== "-") counts.set(r.seq[i], (counts.get(r.seq[i]) || 0) + 1);
const [aa, n] = [...counts].sort((a, b) => b[1] - a[1])[0] || ["-", 0];
return { aa, n, share: n / rows.length };
});
svg.replaceChildren(svg.querySelector("title"));
svg.setAttribute("width", ncol * W);
svg.setAttribute("height", TOP + rows.length * H);
commonest.forEach(({ share }, i) => el("rect", { x: i * W + 1, y: BAR - share * BAR, width: W - 2, height: share * BAR,
fill: share === 1 ? "#3b4659" : "#7d8899" }, svg));
rows.forEach((r, j) => [...r.seq].forEach((aa, i) => {
el("rect", { x: i * W, y: TOP + j * H, width: W, height: H, fill: aa === "-" ? "#ffffff" : colourOf(aa) }, svg);
el("text", { x: i * W + W / 2, y: TOP + j * H + 12, "text-anchor": "middle" }, svg).textContent = aa;
}));
cursor = el("rect", { y: 0, width: W, height: TOP + rows.length * H, fill: "none", stroke: "#000", "stroke-width": 2 }, svg);
const invariant = commonest.filter(c => c.share === 1).length;
svg.querySelector("title").textContent = "Alignment of " + rows.length + " sequences over " + ncol +
" columns, coloured by type of residue. " + invariant + " columns are the same in every sequence.";
document.getElementById("labels").replaceChildren(...rows.map(r => {
const d = document.createElement("div"); d.textContent = r.name; return d;
}));
slider.max = ncol;
}
// Select a column (counting from 1): move the outline, scroll it into view and write the text version
function select(column) {
slider.value = column;
document.getElementById("col-out").textContent = column;
cursor.setAttribute("x", (column - 1) * W);
const x = (column - 1) * W;
if (x < scroller.scrollLeft || x + W > scroller.scrollLeft + scroller.clientWidth) {
scroller.scrollLeft = x - scroller.clientWidth / 2;
}
const c = commonest[column - 1];
document.getElementById("summary").textContent = "Column " + column + ": " +
(c.share === 1 ? "all " + rows.length + " sequences have " + c.aa + "."
: c.n + " of " + rows.length + " sequences have " + c.aa + ", the commonest residue here.");
document.getElementById("cells").replaceChildren(...rows.map(r => {
const li = document.createElement("li");
const n = residueNumber(r.seq, column);
li.textContent = r.name + ": " + (n === null ? "gap" : r.seq[column - 1] + n);
return li;
}));
}
document.getElementById("legend").replaceChildren(...CLASSES.map(c => {
const li = document.createElement("li");
li.style.background = c.colour;
li.textContent = c.letters + " " + c.name;
return li;
}));
svg.addEventListener("click", e => {
const x = e.clientX - svg.getBoundingClientRect().left;
select(Math.min(rows[0].seq.length, Math.floor(x / W) + 1));
});
slider.addEventListener("input", () => select(Number(slider.value)));
document.getElementById("sickle").addEventListener("click", () => select(7));
// A file chosen here is read by the browser. Nothing is uploaded.
document.getElementById("file").addEventListener("change", async e => {
const file = e.target.files[0];
if (!file) return;
const newRows = parseFasta(await file.text());
const lengths = new Set(newRows.map(r => r.seq.length));
if (newRows.length < 2 || lengths.size !== 1) {
document.getElementById("summary").textContent = file.name + " is not an alignment: it needs two or more " +
"sequences of the same length, with gaps as dashes. Align the sequences first.";
return;
}
draw(newRows);
document.getElementById("heading").textContent = "Your alignment: " + file.name;
document.getElementById("sickle").hidden = true; // column 7 means nothing in someone else's alignment
select(1);
});
draw(parseFasta(FASTA));
select(7);
</script>
</body>
</html>How it is built:
- Letters and colours. Each residue is a coloured rectangle with its letter on top, so colour is never the only clue. Six of the seven colours come from the Okabe-Ito palette. We checked all seven in a simulation of the three common kinds of colour blindness, as Hattab and colleagues recommend [5], and every pair of residue classes stayed clearly different. Black letters keep a contrast of at least 5.4:1 on every colour.
- Conservation. The bars show the share of species that carry the commonest residue in each column. Columns that are the same in every species are darker: 77 of the 147 here.
- Keyboard and screen readers. A range input chooses the column, so the arrow keys work, and the list below the alignment gives each species’ residue with its number in its own sequence. The summary sentence sits in an
aria-liveregion. - Phones. The alignment scrolls sideways inside its own box, so the page never becomes wider than the screen.
- Students’ own files. The page reads the chosen file with
file.text()and checks that every row has the same length before it draws.
Show one DNA sequence and its translation
SeqViz shows one DNA sequence with its complementary strand, its annotations and its translation. Here it shows the first exon of HBB in the direction the gene is read. The button makes the sickle-cell change, so students can watch codon 7 turn from GAG to GTG, and the amino acid from E to V.
Code seqviz-exon.html
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SeqViz: HBB exon 1 with its translation and the sickle-cell base</title>
<!-- SeqViz 3.10.25: one script that includes React. It defines window.seqviz. -->
<script src="https://cdn.jsdelivr.net/npm/seqviz@3.10.25/dist/seqviz.min.js"
integrity="sha384-DC85VXIJ5mCuWR5xbBNpTXzPgV13jqBg8cJ+NWQtITy57e0vYKXvFYPFUty60qSz"
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 8px; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; }
button { font: inherit; font-size: 14px; padding: 4px 9px; }
button[aria-pressed="true"] { background: #172033; color: #fff; border-color: #172033; } /* shows that the change is made */
#info { font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; color: #3b4659; min-height: 2.9em; margin: 6px 0 6px; }
#viewer { height: 300px; border: 1px solid #c3cbd8; border-radius: 6px; }
/* SeqViz pads its scroller by 10px without counting the padding in its height, so it would spill out of the box */
#viewer .la-vz-linear-scroller { box-sizing: border-box; }
</style>
</head>
<body>
<h1>HBB exon 1, read in the direction of the gene</h1>
<p>The orange base is c.20A in codon 7 (GAG, glutamate). In sickle-cell disease it is T, so the codon reads GTG (valine).</p>
<div id="viewer"></div>
<p id="info" aria-live="polite">Drag across some bases, or click an amino acid, to see where it sits.</p>
<div class="controls">
<button type="button" id="change" aria-pressed="false">Make the sickle-cell change</button>
</div>
<script>
// GRCh38 chr11:5,226,930-5,227,071, reverse-complemented because HBB lies on the reverse strand
const EXON1 = "ACATTTGCTTCTGACACAACTGTGTTCACTAGCAACCTCAAACAGACACCATGGTGCATCTGACTCCTGAGGAGAAGTCTGCCGTTACTGCCCTGTGGGGCAAGGTGAACGTGGATGAAGTTGGTGGTGAGGCCCTGGGCAG";
const ATG = 50; // index of c.1, counting from 0
const C20 = ATG + 19; // index of c.20, the sickle-cell base
const info = document.getElementById("info");
// A position in the exon (counting from 0) as a coding-sequence (c.) number. Bases before the ATG count back from c.-1.
const cNumber = i => i >= ATG ? "c." + (i - ATG + 1) : "c.-" + (ATG - i);
function props(seq) {
return {
name: "HBB exon 1",
seq,
viewer: "linear",
disableExternalFonts: true, // otherwise SeqViz downloads a font from Google Fonts
zoom: { linear: 60 },
// Positions count from 0, and the end is not included: start 0, end 50 is the first 50 bases
annotations: [
{ name: "5′ untranslated region", start: 0, end: ATG, direction: 1, color: "#cfd8dc" },
{ name: "Coding sequence (exon 1 part)", start: ATG, end: seq.length, direction: 1, color: "#90caf9" }
],
translations: [{ name: "β-globin, codons 1-30", start: ATG, end: ATG + 90, direction: 1 }], // whole codons only
highlights: [{ start: C20, end: C20 + 1, color: "#ff8a65" }],
onSelection: sel => {
if (!sel || !sel.length) return;
const first = sel.start, last = sel.end - 1;
info.textContent = (sel.name ? sel.name + ": " : "") + "bases " + (first + 1) + "-" + (last + 1) + " of this exon, " +
cNumber(first) + " to " + cNumber(last) + " (" + sel.length + " bases)";
}
};
}
function start() {
const viewer = seqviz.Viewer("viewer", props(EXON1));
viewer.render();
// setState() replaces the options and draws the viewer again
document.getElementById("change").addEventListener("click", e => {
const on = e.currentTarget.getAttribute("aria-pressed") !== "true";
e.currentTarget.setAttribute("aria-pressed", String(on));
// The label stays the same: aria-pressed tells screen readers whether the change is made
const seq = EXON1.slice(0, C20) + (on ? "T" : "A") + EXON1.slice(C20 + 1);
viewer.setState(props(seq));
info.textContent = on ? "c.20A>T: codon 7 now reads GTG, which codes for valine (V)."
: "c.20 is A again: codon 7 reads GAG, which codes for glutamate (E).";
});
}
if (window.seqviz) start();
else info.textContent = "SeqViz did not load. Check the connection and reload the page.";
</script>
</body>
</html>Some details:
- Drawing.
seqviz.Viewer(element, options).render()draws the viewer, andsetState(options)draws it again with new options. - Positions in
annotations,translationsandhighlightscount from 0 and leave out the end. The sickle-cell base, c.20, is base 70 of this exon, so its highlight isstart: 69, end: 70. The read-out turns selections back into exon positions and c. numbers. - Strand. The exon is the reverse complement of GRCh38 chr11:5,226,930-5,227,071, because HBB lies on the reverse strand. The genome browsers page shows the same base on the chromosome.
- Fonts. SeqViz downloads a font from Google Fonts when it starts, unless you set
disableExternalFonts: true, as this example does.
Things that catch people out
- Residue numbers. Say whose numbering you use, and never print an alignment column as if it were a residue number. See get residue numbers right.
- Phones. Nightingale and react-msaview fit a 390-pixel screen only by showing fewer columns or very small letters. The examples on this page show fewer columns. The SVG version scrolls inside its own box.
- Screen readers and keyboards. Nightingale and react-msaview draw on a canvas, which screen readers cannot read, and Nightingale’s view cannot be moved from the keyboard. Give every task a text version, as the read-outs here do, and see accessibility.
- Colour schemes. Clustal and Zappo colour residues by type or chemistry, not by right and wrong. In the same simulation, two pairs of Nightingale’s Clustal colours came close: green and yellow for one kind of colour blindness, orange and pink for another. Keep the letters visible and name the scheme.
- Gaps at the start. The horse and dog rows begin with a gap, but those species do not lack a residue: their UniProt entries describe the mature protein, which has already lost its first methionine.
- One copy of the core. The import map gives every Nightingale component the same copy of the core, so load all of them from the core’s release, here 5.11.0, even when one component has a newer patch release.
- Requests to other sites. Before you publish, open your browser’s developer tools and check the network tab for requests you did not expect, such as SeqViz’s font.
- Versions. Each example names an exact version of its library. When you update, read the release notes and test the page again.
Licences and credits
Nightingale, react-msaview and SeqViz are under the MIT licence; their code is on GitHub at ebi-webcomponents/nightingale, GMOD/JBrowseMSA and Lattice-Automation/seqviz. UniProt data are under the Creative Commons Attribution 4.0 licence, so credit UniProt when you reuse its sequences or annotations. If you publish teaching material built on these tools or on the alignment, cite the papers below.
References
- Yachdav G, Wilzbach S, Rauscher B, et al. (2016). MSAViewer: interactive JavaScript visualization of multiple sequence alignments. Bioinformatics 32(22): 3501–3503. doi:10.1093/bioinformatics/btw474
- Salazar GA, Luciani A, Watkins X, et al. (2023). Nightingale: web components for protein feature visualization. Bioinformatics Advances 3(1): vbad064. doi:10.1093/bioadv/vbad064
- Deorowicz S, Debudaj-Grabysz A, Gudyś A (2016). FAMSA: fast and accurate multiple sequence alignment of huge protein families. Scientific Reports 6: 33964. doi:10.1038/srep33964
- UniProt Consortium (2025). UniProt: the Universal Protein Knowledgebase in 2025. Nucleic Acids Research 53(D1): D609–D617. doi:10.1093/nar/gkae1010
- 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