Below is a toy 60-nucleotide genomic target region. Each button generates a set of 20-nt candidate guides — each must sit next to a PAM site (the three-letter sequence NGG required by the most common Cas9). The scorer checks how many near-matches exist elsewhere in the simulated genome fragment and assigns a specificity score: higher is safer.
<!-- Interactive CRISPR guide-RNA scorer -->
<p class="hint">Hover a guide card to highlight it in the genome. Specificity score (0–100): higher means fewer dangerous off-target near-matches. The PAM (NGG) is shown in red — Cas9 requires it immediately after the 20-nt guide.</p>
<div class="genome-box">
<div class="genome-label">Simulated genome fragment (116 nt)</div>
<div id="genome-display" class="genome-seq"></div>
</div>
<div class="controls">
<button id="btn-generate" type="button">Find & score guides</button>
<button id="btn-clear" type="button" class="ghost">Clear</button>
</div>
<div id="guide-list" class="guide-list"></div>
<div id="status" class="status"></div>
/* Styles for CRISPR guide scorer */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .9rem; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.genome-box { background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 8px; padding: .5rem .8rem; margin-bottom: .6rem; }
.genome-label { font-size: .75rem; color: #5a7088; margin-bottom: .2rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
.genome-seq { font: 700 13px ui-monospace, monospace; color: #1d3557; word-break: break-all; line-height: 1.7; letter-spacing: .08em; }
.genome-seq span.highlight { background: #f4d03f; border-radius: 3px; }
.genome-seq span.pam { background: #e63946; color: #fff; border-radius: 3px; }
.controls { display: flex; gap: .5rem; margin-bottom: .6rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.guide-list { display: flex; flex-direction: column; gap: .4rem; }
.guide-card { display: flex; align-items: center; gap: .5rem; padding: .4rem .7rem; border-radius: 8px; border: 1px solid #cdd9e3; background: #f5f8fa; flex-wrap: wrap; }
.guide-seq { font: 700 12px ui-monospace, monospace; color: #1d3557; letter-spacing: .06em; flex: 0 0 auto; }
.guide-pam { font: 600 12px ui-monospace, monospace; color: #e63946; margin-left: .1rem; }
.guide-scores { display: flex; gap: .5rem; align-items: center; flex: 1 1 auto; }
.score-bar-wrap { display: flex; align-items: center; gap: .3rem; flex: 1 1 80px; }
.score-label { font-size: .72rem; color: #5a7088; white-space: nowrap; }
.score-bar { height: 8px; border-radius: 4px; transition: width .3s; }
.score-bar.specificity { background: #2a9d8f; }
.score-bar.offtarget { background: #e63946; }
.score-value { font: 700 12px system-ui; min-width: 2.2em; text-align: right; }
.badge { font: 700 10px system-ui; padding: .15rem .4rem; border-radius: 999px; white-space: nowrap; }
.badge.best { background: #2a9d8f; color: #fff; }
.badge.risky { background: #e63946; color: #fff; }
.status { font-size: .85rem; font-weight: 600; margin-top: .4rem; min-height: 1.2em; color: #5a7088; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulated genome fragment. In a real tool this would be the full
// reference genome (hg38 etc.) indexed for fast approximate search.
const GENOME = 'ATGCTAGCTTGGAACGTACCGGATGCAATCGGAACGATCGGTAGCTTGCAGCTAGCTTGGAACGATCGGAACGATAATCGGAACGATCGGAACGATCGGAACGATCGGAACGATCGG';
// PAM required by SpCas9: the two bases after the guide must be GG (full PAM = NGG).
const PAM = 'GG';
// GC content: good guides have 40-65% GC. Too low = weak binding, too high = secondary structures.
// Here we allow 35-75% as a slightly looser filter for the toy genome.
function gcContent(seq) {
const gc = (seq.match(/[GC]/g) || []).length;
return gc / seq.length;
}
// Hamming distance: count positions where two equal-length strings differ.
function hamming(a, b) {
let d = 0;
for (let i = 0; i < a.length && i < b.length; i++) if (a[i] !== b[i]) d++;
return d;
}
// Off-target risk: scan every 20-nt window in the genome. A window is a threat if its
// seed (last 12 nt) nearly matches and the full Hamming distance is <= 4. Closer matches count more.
function offTargetRisk(guide, genome, ignore) {
const SEED_LEN = 12;
const seed = guide.slice(-SEED_LEN);
let risk = 0;
for (let i = 0; i <= genome.length - guide.length; i++) {
if (i === ignore) continue;
const window = genome.slice(i, i + guide.length);
const windowSeed = window.slice(-SEED_LEN);
if (hamming(seed, windowSeed) <= 1) {
const dist = hamming(guide, window);
if (dist <= 4) risk += Math.pow(2, 4 - dist);
}
}
return risk;
}
// Specificity score: 100 = no off-targets; lower = more dangerous near-matches.
function specificityScore(risk) {
return Math.max(0, Math.round(100 / (1 + risk * 0.5)));
}
function findGuides(genome) {
const guides = [];
// Scan genome for guides (20-nt window + NGG PAM at positions i+20..i+21).
for (let i = 0; i <= genome.length - 23; i++) {
const pam2 = genome.slice(i + 20, i + 22);
if (pam2 === PAM) {
const guide = genome.slice(i, i + 20);
const gc = gcContent(guide);
if (gc >= 0.35 && gc <= 0.75) {
const risk = offTargetRisk(guide, genome, i);
const spec = specificityScore(risk);
guides.push({ guide, pos: i, gc, risk, spec });
}
}
}
return guides;
}
function renderGenome(genome, highlights) {
const el = document.getElementById('genome-display');
let html = '';
for (let i = 0; i < genome.length; i++) {
const ch = highlights && highlights[i];
if (ch === 'guide') html += `<span class="highlight">${genome[i]}</span>`;
else if (ch === 'pam') html += `<span class="pam">${genome[i]}</span>`;
else html += genome[i];
}
el.innerHTML = html;
}
function renderGuides(guides) {
const list = document.getElementById('guide-list');
list.innerHTML = '';
const status = document.getElementById('status');
if (!guides.length) {
status.textContent = 'No valid guides found with good GC content.';
return;
}
const maxSpec = Math.max(...guides.map(g => g.spec));
guides.sort((a, b) => b.spec - a.spec);
guides.slice(0, 6).forEach((g, idx) => {
const card = document.createElement('div');
card.className = 'guide-card';
const isBest = g.spec === maxSpec && idx === 0;
const isRisky = g.spec < 30;
const barW = g.spec;
const riskBarW = Math.min(100, g.risk * 8);
const gcPct = Math.round(g.gc * 100);
let badge = '';
if (isBest) badge = `<span class="badge best">Best</span>`;
else if (isRisky) badge = `<span class="badge risky">Risky</span>`;
card.innerHTML = `
<span class="guide-seq" title="Position: ${g.pos}">${g.guide}</span>
<span class="guide-pam">NGG</span>
<div class="guide-scores">
<div class="score-bar-wrap">
<span class="score-label">Spec:</span>
<div style="flex:1;background:#e0e0e0;border-radius:4px;overflow:hidden;height:8px">
<div class="score-bar specificity" style="width:${barW}%"></div>
</div>
<span class="score-value" style="color:#2a9d8f">${g.spec}</span>
</div>
<div class="score-bar-wrap">
<span class="score-label">GC:</span>
<span class="score-value" style="color:#555">${gcPct}%</span>
</div>
</div>
${badge}
`;
card.addEventListener('mouseenter', () => {
const map = {};
for (let j = g.pos; j < g.pos + 20; j++) map[j] = 'guide';
for (let j = g.pos + 20; j < g.pos + 23; j++) map[j] = 'pam';
renderGenome(GENOME, map);
});
card.addEventListener('mouseleave', () => renderGenome(GENOME, null));
list.appendChild(card);
});
const best = guides[0];
status.textContent = i18nFmt('Found %n% guide(s). Best specificity score: %spec%.', { n: guides.length, spec: best.spec });
}
document.getElementById('btn-generate').addEventListener('click', () => {
const guides = findGuides(GENOME);
renderGuides(guides);
});
document.getElementById('btn-clear').addEventListener('click', () => {
document.getElementById('guide-list').innerHTML = '';
document.getElementById('status').textContent = '';
renderGenome(GENOME, null);
});
renderGenome(GENOME, null);
<!-- Interactive CRISPR guide-RNA scorer -->
<p class="hint">Hover a guide card to highlight it in the genome. Specificity score (0–100): higher means fewer dangerous off-target near-matches. The PAM (NGG) is shown in red — Cas9 requires it immediately after the 20-nt guide.</p>
<div class="genome-box">
<div class="genome-label">Simulated genome fragment (116 nt)</div>
<div id="genome-display" class="genome-seq"></div>
</div>
<div class="controls">
<button id="btn-generate" type="button">Find & score guides</button>
<button id="btn-clear" type="button" class="ghost">Clear</button>
</div>
<div id="guide-list" class="guide-list"></div>
<div id="status" class="status"></div>
/* Styles for CRISPR guide scorer */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .9rem; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.genome-box { background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 8px; padding: .5rem .8rem; margin-bottom: .6rem; }
.genome-label { font-size: .75rem; color: #5a7088; margin-bottom: .2rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
.genome-seq { font: 700 13px ui-monospace, monospace; color: #1d3557; word-break: break-all; line-height: 1.7; letter-spacing: .08em; }
.genome-seq span.highlight { background: #f4d03f; border-radius: 3px; }
.genome-seq span.pam { background: #e63946; color: #fff; border-radius: 3px; }
.controls { display: flex; gap: .5rem; margin-bottom: .6rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.guide-list { display: flex; flex-direction: column; gap: .4rem; }
.guide-card { display: flex; align-items: center; gap: .5rem; padding: .4rem .7rem; border-radius: 8px; border: 1px solid #cdd9e3; background: #f5f8fa; flex-wrap: wrap; }
.guide-seq { font: 700 12px ui-monospace, monospace; color: #1d3557; letter-spacing: .06em; flex: 0 0 auto; }
.guide-pam { font: 600 12px ui-monospace, monospace; color: #e63946; margin-left: .1rem; }
.guide-scores { display: flex; gap: .5rem; align-items: center; flex: 1 1 auto; }
.score-bar-wrap { display: flex; align-items: center; gap: .3rem; flex: 1 1 80px; }
.score-label { font-size: .72rem; color: #5a7088; white-space: nowrap; }
.score-bar { height: 8px; border-radius: 4px; transition: width .3s; }
.score-bar.specificity { background: #2a9d8f; }
.score-bar.offtarget { background: #e63946; }
.score-value { font: 700 12px system-ui; min-width: 2.2em; text-align: right; }
.badge { font: 700 10px system-ui; padding: .15rem .4rem; border-radius: 999px; white-space: nowrap; }
.badge.best { background: #2a9d8f; color: #fff; }
.badge.risky { background: #e63946; color: #fff; }
.status { font-size: .85rem; font-weight: 600; margin-top: .4rem; min-height: 1.2em; color: #5a7088; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulated genome fragment. In a real tool this would be the full
// reference genome (hg38 etc.) indexed for fast approximate search.
const GENOME = 'ATGCTAGCTTGGAACGTACCGGATGCAATCGGAACGATCGGTAGCTTGCAGCTAGCTTGGAACGATCGGAACGATAATCGGAACGATCGGAACGATCGGAACGATCGGAACGATCGG';
// PAM required by SpCas9: the two bases after the guide must be GG (full PAM = NGG).
const PAM = 'GG';
// GC content: good guides have 40-65% GC. Too low = weak binding, too high = secondary structures.
// Here we allow 35-75% as a slightly looser filter for the toy genome.
function gcContent(seq) {
const gc = (seq.match(/[GC]/g) || []).length;
return gc / seq.length;
}
// Hamming distance: count positions where two equal-length strings differ.
function hamming(a, b) {
let d = 0;
for (let i = 0; i < a.length && i < b.length; i++) if (a[i] !== b[i]) d++;
return d;
}
// Off-target risk: scan every 20-nt window in the genome. A window is a threat if its
// seed (last 12 nt) nearly matches and the full Hamming distance is <= 4. Closer matches count more.
function offTargetRisk(guide, genome, ignore) {
const SEED_LEN = 12;
const seed = guide.slice(-SEED_LEN);
let risk = 0;
for (let i = 0; i <= genome.length - guide.length; i++) {
if (i === ignore) continue;
const window = genome.slice(i, i + guide.length);
const windowSeed = window.slice(-SEED_LEN);
if (hamming(seed, windowSeed) <= 1) {
const dist = hamming(guide, window);
if (dist <= 4) risk += Math.pow(2, 4 - dist);
}
}
return risk;
}
// Specificity score: 100 = no off-targets; lower = more dangerous near-matches.
function specificityScore(risk) {
return Math.max(0, Math.round(100 / (1 + risk * 0.5)));
}
function findGuides(genome) {
const guides = [];
// Scan genome for guides (20-nt window + NGG PAM at positions i+20..i+21).
for (let i = 0; i <= genome.length - 23; i++) {
const pam2 = genome.slice(i + 20, i + 22);
if (pam2 === PAM) {
const guide = genome.slice(i, i + 20);
const gc = gcContent(guide);
if (gc >= 0.35 && gc <= 0.75) {
const risk = offTargetRisk(guide, genome, i);
const spec = specificityScore(risk);
guides.push({ guide, pos: i, gc, risk, spec });
}
}
}
return guides;
}
function renderGenome(genome, highlights) {
const el = document.getElementById('genome-display');
let html = '';
for (let i = 0; i < genome.length; i++) {
const ch = highlights && highlights[i];
if (ch === 'guide') html += `<span class="highlight">${genome[i]}</span>`;
else if (ch === 'pam') html += `<span class="pam">${genome[i]}</span>`;
else html += genome[i];
}
el.innerHTML = html;
}
function renderGuides(guides) {
const list = document.getElementById('guide-list');
list.innerHTML = '';
const status = document.getElementById('status');
if (!guides.length) {
status.textContent = 'No valid guides found with good GC content.';
return;
}
const maxSpec = Math.max(...guides.map(g => g.spec));
guides.sort((a, b) => b.spec - a.spec);
guides.slice(0, 6).forEach((g, idx) => {
const card = document.createElement('div');
card.className = 'guide-card';
const isBest = g.spec === maxSpec && idx === 0;
const isRisky = g.spec < 30;
const barW = g.spec;
const riskBarW = Math.min(100, g.risk * 8);
const gcPct = Math.round(g.gc * 100);
let badge = '';
if (isBest) badge = `<span class="badge best">Best</span>`;
else if (isRisky) badge = `<span class="badge risky">Risky</span>`;
card.innerHTML = `
<span class="guide-seq" title="Position: ${g.pos}">${g.guide}</span>
<span class="guide-pam">NGG</span>
<div class="guide-scores">
<div class="score-bar-wrap">
<span class="score-label">Spec:</span>
<div style="flex:1;background:#e0e0e0;border-radius:4px;overflow:hidden;height:8px">
<div class="score-bar specificity" style="width:${barW}%"></div>
</div>
<span class="score-value" style="color:#2a9d8f">${g.spec}</span>
</div>
<div class="score-bar-wrap">
<span class="score-label">GC:</span>
<span class="score-value" style="color:#555">${gcPct}%</span>
</div>
</div>
${badge}
`;
card.addEventListener('mouseenter', () => {
const map = {};
for (let j = g.pos; j < g.pos + 20; j++) map[j] = 'guide';
for (let j = g.pos + 20; j < g.pos + 23; j++) map[j] = 'pam';
renderGenome(GENOME, map);
});
card.addEventListener('mouseleave', () => renderGenome(GENOME, null));
list.appendChild(card);
});
const best = guides[0];
status.textContent = i18nFmt('Found %n% guide(s). Best specificity score: %spec%.', { n: guides.length, spec: best.spec });
}
document.getElementById('btn-generate').addEventListener('click', () => {
const guides = findGuides(GENOME);
renderGuides(guides);
});
document.getElementById('btn-clear').addEventListener('click', () => {
document.getElementById('guide-list').innerHTML = '';
document.getElementById('status').textContent = '';
renderGenome(GENOME, null);
});
renderGenome(GENOME, null);
Notice that two guides with identical GC content can have very different specificity scores depending on whether their sequence appears almost-exactly elsewhere. A guide with a single off-target site that differs by only one base is far more dangerous than one with ten sites that differ by four bases — the mismatch penalty model captures that difference.
Comments
Loading comments...