WCAG // AUDIT

Quick accessibility checks.

Run heuristic WCAG checks. Paste HTML below, or drag the bookmarklet to your bookmarks bar to audit any page you visit.

01

Bookmarklet

Drag this link to your bookmarks bar. Click it on any page to audit.

WCAG Audit

The bookmarklet runs the same checks shown below directly against the page you're viewing. Findings appear as a panel overlay on that page.

02

Audit HTML directly

low contrast text

Title

Skipped h2

`; // ---------- Audit checks ---------- function runChecks(doc){ const findings=[]; function add(severity,category,message,el,fix){ findings.push({severity,category,message,selector:cssPath(el),fix}); } function cssPath(el){ if(!el||el.nodeType!==1)return"(unknown)"; if(el.id)return"#"+el.id; let path=[]; let cur=el; while(cur&&cur.nodeType===1&&cur!==doc.documentElement&&path.length<4){ let part=cur.nodeName.toLowerCase(); if(cur.className&&typeof cur.className==="string"){ const cls=cur.className.split(/\s+/).filter(Boolean)[0]; if(cls)part+="."+cls; } const sib=cur.parentNode?[...cur.parentNode.children].filter(c=>c.nodeName===cur.nodeName):[]; if(sib.length>1){ const i=sib.indexOf(cur); part+=`:nth-of-type(${i+1})`; } path.unshift(part); cur=cur.parentNode; } return path.join(" > "); } // 1. Document language if(!doc.documentElement.getAttribute("lang")){ add("major","language"," element is missing a lang attribute. Screen readers won't pronounce content correctly.",doc.documentElement,'Add lang="en" (or appropriate locale) to .'); } // 2. Page title if(!doc.title||doc.title.trim()===""){ add("major","title","Page has no . Screen readers and tab labels need it.",doc.documentElement,"Add a <title> element with descriptive text."); } // 3. Images without alt doc.querySelectorAll("img").forEach(img=>{ if(!img.hasAttribute("alt")){ add("blocker","alt-text",`Image is missing the alt attribute. src="${img.getAttribute('src')||''}".`,img,'Add alt="" for decorative images, or alt="description" for content images.'); } }); // 4. Form inputs without labels doc.querySelectorAll("input,textarea,select").forEach(el=>{ if(el.type==="hidden"||el.type==="submit"||el.type==="button"||el.type==="reset")return; const id=el.getAttribute("id"); const hasLabel=id&&doc.querySelector(`label[for="${CSS.escape(id)}"]`); const hasAriaLabel=el.getAttribute("aria-label")||el.getAttribute("aria-labelledby"); const wrappedLabel=el.closest("label"); if(!hasLabel&&!hasAriaLabel&&!wrappedLabel){ add("blocker","form-label","Form control has no associated label.",el,'Add <label for="id">…</label> or wrap the input in a <label>.'); } }); // 5. Heading order const headings=[...doc.querySelectorAll("h1,h2,h3,h4,h5,h6")]; let lastLevel=0; headings.forEach(h=>{ const level=parseInt(h.tagName[1],10); if(lastLevel===0&&level!==1){ add("minor","heading-order",`First heading is <${h.tagName.toLowerCase()}>, not <h1>.`,h,"Start the page with an <h1>."); }else if(level>lastLevel+1&&lastLevel!==0){ add("minor","heading-order",`Heading level skipped from h${lastLevel} to h${level}.`,h,"Don't skip heading levels (e.g. h1 -> h3). Use h2 instead."); } lastLevel=level; }); if(headings.length>0&&!doc.querySelector("h1")){ add("major","heading-order","Page has headings but no <h1>.",doc.body,"Every page should have one (and ideally only one) <h1>."); } // 6. Links without text doc.querySelectorAll("a").forEach(a=>{ const text=(a.textContent||"").trim(); const ariaLabel=a.getAttribute("aria-label"); const hasImg=a.querySelector("img[alt]:not([alt=''])"); const hasTitle=a.getAttribute("title"); if(!text&&!ariaLabel&&!hasImg&&!hasTitle){ add("blocker","link-name","Link has no discernible text.",a,'Add visible text, aria-label, or an <img> with alt text inside.'); } }); // 7. Buttons without text doc.querySelectorAll("button").forEach(b=>{ const text=(b.textContent||"").trim(); const ariaLabel=b.getAttribute("aria-label"); if(!text&&!ariaLabel){ add("blocker","button-name","Button has no accessible name.",b,'Add visible text or aria-label.'); } }); // 8. Color contrast (rough: only inline styles) doc.querySelectorAll("[style]").forEach(el=>{ const s=el.getAttribute("style"); const colorMatch=s.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i); const bgMatch=s.match(/background(?:-color)?\s*:\s*([^;]+)/i); if(colorMatch&&bgMatch){ const fg=parseColor(colorMatch[1].trim()); const bg=parseColor(bgMatch[1].trim()); if(fg&&bg){ const r=contrastRatio(fg,bg); if(r<4.5){ add("major","contrast",`Inline style color contrast is ${r.toFixed(2)}:1 (WCAG AA needs 4.5:1 for body text).`,el,"Darken the foreground or lighten the background."); } } } }); // 9. Empty link or button (whitespace only) doc.querySelectorAll("a,button").forEach(el=>{ const html=el.innerHTML.trim(); if(html===""&&!el.getAttribute("aria-label")&&!el.getAttribute("title")){ add("major","empty-control",`Empty <${el.tagName.toLowerCase()}>.`,el,"Provide content or an aria-label."); } }); return findings; } function parseColor(s){ s=s.trim().toLowerCase(); if(s.startsWith("#")){ let hex=s.slice(1); if(hex.length===3)hex=hex.split("").map(c=>c+c).join(""); if(hex.length!==6)return null; return[parseInt(hex.slice(0,2),16),parseInt(hex.slice(2,4),16),parseInt(hex.slice(4,6),16)]; } const rgb=s.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); if(rgb)return[+rgb[1],+rgb[2],+rgb[3]]; // Named (limited) const NAMED={black:[0,0,0],white:[255,255,255],red:[255,0,0],green:[0,128,0],blue:[0,0,255],gray:[128,128,128],grey:[128,128,128]}; return NAMED[s]||null; } function relLum([r,g,b]){ const f=v=>{v/=255;return v<=.03928?v/12.92:Math.pow((v+.055)/1.055,2.4)}; return.2126*f(r)+.7152*f(g)+.0722*f(b); } function contrastRatio(a,b){ const l1=relLum(a),l2=relLum(b); const hi=Math.max(l1,l2),lo=Math.min(l1,l2); return(hi+.05)/(lo+.05); } // ---------- Render ---------- function render(findings){ $("results-panel").style.display="block"; const counts={blocker:0,major:0,minor:0,nit:0}; for(const f of findings)counts[f.severity]++; $("sev-grid").innerHTML=` <div class="sev-card blocker"><span class="sev-count">${counts.blocker}</span><div class="sev-label">Blocker</div></div> <div class="sev-card major"><span class="sev-count">${counts.major}</span><div class="sev-label">Major</div></div> <div class="sev-card minor"><span class="sev-count">${counts.minor}</span><div class="sev-label">Minor</div></div> <div class="sev-card nit"><span class="sev-count">${counts.nit}</span><div class="sev-label">Nit</div></div> `; if(findings.length===0){ $("findings").innerHTML='<div class="empty">No issues found.</div>'; return; } const order={blocker:0,major:1,minor:2,nit:3}; findings.sort((a,b)=>order[a.severity]-order[b.severity]); let html='<div class="findings">'; for(const f of findings){ html+=`<div class="finding"> <div class="finding-head"> <span class="tag ${escapeHtml(f.severity)}">${escapeHtml(f.severity)}</span> <span class="cat">${escapeHtml(f.category)}</span> </div> <div class="msg">${escapeHtml(f.message)}</div> <div class="sel">${escapeHtml(f.selector||"")}</div> <div class="msg" style="font-size:.8125rem;color:var(--ink-soft);font-style:italic">Fix: ${escapeHtml(f.fix||"")}</div> </div>`; } html+="</div>"; $("findings").innerHTML=html; } $("audit-btn").addEventListener("click",()=>{ const html=$("html-input").value; if(!html.trim())return; const parser=new DOMParser(); const doc=parser.parseFromString(html,"text/html"); const findings=runChecks(doc); render(findings); }); $("example-btn").addEventListener("click",()=>{$("html-input").value=EXAMPLE;$("audit-btn").click()}); $("clear-btn").addEventListener("click",()=>{$("html-input").value="";$("results-panel").style.display="none"}); // Build the bookmarklet const BOOKMARKLET=`(function(){ const checks=${runChecks.toString()}; const parseColor=${parseColor.toString()}; const relLum=${relLum.toString()}; const contrastRatio=${contrastRatio.toString()}; const findings=checks(document); let html='<div style="position:fixed;top:10px;right:10px;width:380px;max-height:80vh;overflow-y:auto;background:#fff;border:2px solid #7c2d12;border-radius:4px;padding:12px;font-family:system-ui;font-size:13px;z-index:2147483647;color:#1f1812;box-shadow:0 4px 12px rgba(0,0,0,.2)">'; html+='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"><strong>WCAG Audit ('+findings.length+' findings)</strong><button id="__wcagclose" style="background:#7c2d12;color:#fff;border:none;padding:2px 8px;cursor:pointer;border-radius:2px">X</button></div>'; if(findings.length===0)html+='<em>No issues.</em>'; else findings.forEach(f=>{html+='<div style="margin:6px 0;padding:6px;background:#fbe5d4;border-radius:2px"><strong>'+f.severity.toUpperCase()+' '+f.category+':</strong> '+f.message+'<br><code style="font-size:11px;color:#7c2d12">'+f.selector+'</code><br><em style="font-size:11px">Fix: '+f.fix+'</em></div>'}); html+='</div>'; const div=document.createElement('div'); div.innerHTML=html; document.body.appendChild(div); div.querySelector('#__wcagclose').onclick=()=>div.remove(); })()`; $("bookmarklet").href="javascript:"+encodeURIComponent(BOOKMARKLET); $("bookmarklet").addEventListener("click",e=>{e.preventDefault();alert("Drag this link to your bookmarks bar — don't click it.")}); function applyTheme(t){ document.body.dataset.theme=t; $("theme-label").textContent=t==="dark"?"Light":"Dark"; $("theme-btn").setAttribute("aria-pressed",String(t==="dark")); const icon=$("theme-icon"); if(t==="dark")icon.innerHTML='<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>'; else icon.innerHTML='<circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path>'; } $("theme-btn").addEventListener("click",()=>applyTheme(document.body.dataset.theme==="dark"?"light":"dark")); if(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)applyTheme("dark"); </script> </body> </html>