Checking contrast

Checking contrast

Why this page exists

The rule is on Cards and surfaces: check what sits on a surface, not just the text in it. This page is how to actually check it, because the cases that break are the ones you cannot see.

A worked example. The dashboard shortcut rows fill with brand purple on hover and their label inherits white from that fill. A muted-ink rule scoped body.ts-glass-page .card p matched them, because those rows are cards whose label is a <p>. Setting a colour on the <p> directly beats the inherited white, since inheritance loses to any rule that names the element. The label stayed #5b5872 on #712F79: 1.29:1, against a 4.5 threshold.

Nothing about that is visible in a screenshot, a design review, or the page at rest. The surface only exists while the pointer is on it.

The two thresholds, which is why this gets missed

  • 4.5:1 for text.
  • 3:1 for non-text: a fill, an icon, a border, a state indicator.

A state indicator can pass its text check and still fail as an indicator. Both numbers matter and they are not the same number.

The sweep

Paste into the console on any page. It reports every interactive element whose text would fall below 4.5:1 in a hover, focus or active state.

For each link, button and list item it finds any rule giving it or an ancestor a background in an interaction state, resolves which colour rule wins on the text in that state by specificity and source order, and computes the contrast.

(() => {
  const lum=c=>{const f=v=>{v/=255;return v<=0.03928?v/12.92:Math.pow((v+0.055)/1.055,2.4);};
    return 0.2126*f(c[0])+0.7152*f(c[1])+0.0722*f(c[2]);};
  const pr=s=>(s.match(/\d+(\.\d+)?/g)||[]).map(Number);
  const resolve=v=>{v=(v||'').trim();
    const m=v.match(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/);
    if(m){const r=getComputedStyle(document.documentElement).getPropertyValue(m[1]).trim();v=r||m[2]||'';}
    v=v.trim();
    if(v.startsWith('#')){const h=v.replace('#','');
      const e=h.length===3?h.split('').map(x=>x+x).join(''):h;
      return [0,2,4].map(i=>parseInt(e.slice(i,i+2),16));}
    const n=pr(v); return n.length>=3?n.slice(0,3):null;};
  const ratio=(a,b)=>{if(!a||!b)return null;const L1=lum(a),L2=lum(b);
    return Math.round(((Math.max(L1,L2)+0.05)/(Math.min(L1,L2)+0.05))*100)/100;};
  const spec=s=>{const i=(s.match(/#[\w-]+/g)||[]).length,
    c=(s.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(\([^)]*\))?/g)||[]).length,
    e=(s.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g)||[]).length; return i*100+c*10+e;};
  const all=[]; let o=0;
  for(const s of document.styleSheets){let R;try{R=s.cssRules;}catch(e){continue;}
    (function w(l){for(const r of l){
      if(r.cssRules&&!r.selectorText){w(r.cssRules);continue;}
      if(!r.selectorText||!r.style)continue; o++;
      const bg=r.style.getPropertyValue('background-color')||r.style.getPropertyValue('background');
      const col=r.style.getPropertyValue('color');
      for(const p of r.selectorText.split(',').map(x=>x.trim())){
        const st=/:hover|:focus|\.active|\.is-active/.test(p);
        if(bg&&st)all.push({p,b:p.replace(/:hover|:focus/g,''),bg,s:spec(p),o,k:'bg'});
        if(col)all.push({p,b:p.replace(/:hover|:focus/g,''),col,s:spec(p),o,k:'col'});
      }}})(R);}
  const out=[];
  document.querySelectorAll('a,button,li,[role=button]').forEach(el=>{
    if(!el.getBoundingClientRect().width)return;
    let fill=null,n=el;
    for(let d=0;d<3&&n;d++,n=n.parentElement){
      for(const r of all){if(r.k!=='bg')continue;let m=false;try{m=n.matches(r.b);}catch(e){}
        if(m){const c=resolve(r.bg);if(c&&(!fill||r.s>=fill.s))fill={c,s:r.s,sel:r.p};}}
      if(fill)break;}
    if(!fill)return;
    [el,...el.querySelectorAll('p,span')]
      .filter(t=>!t.children.length&&(t.textContent||'').trim()).slice(0,2)
      .forEach(t=>{
        let win=null;
        for(const r of all){if(r.k!=='col')continue;let m=false;try{m=t.matches(r.b);}catch(e){}
          if(m&&(!win||r.s>win.s||(r.s===win.s&&r.o>win.o)))win=r;}
        const rr=ratio(win?resolve(win.col):null, fill.c);
        if(rr!==null&&rr<4.5)out.push({text:(t.textContent||'').trim().slice(0,22),
          fill:fill.sel.slice(0,40), ink:(win&&win.p||'').slice(0,40), contrast:rr});});
  });
  const seen=new Set();
  return out.filter(f=>{const k=f.text+f.fill;if(seen.has(k))return false;seen.add(k);return true;});
})()

An empty array is a pass. Anything returned gives you the text, the rule supplying the fill, the rule supplying the ink, and the ratio.

What it does not catch, and please do not over-trust it

  • Only what CSS declares. Colour set inline or by JavaScript is invisible to it.
  • Only states the page can currently reach. A view that needs real data, an error state, an empty state: load them and run it again.
  • It is our reading of the cascade, not the browser’s. It found the shortcut row correctly, which is evidence, not proof. A rule it clears could still fail in a way this does not model.

It rules out one specific thing well: a broad colour rule pinning text over an interaction fill. That is the bug it was written for.

And it cannot see a control that is wrong in what it promises rather than in what it measures. A dropdown trigger with no caret passes every check on this page and is still wrong. See Controls and affordance.

The one-line version of all of it

A rule that names an element beats one that only reaches it by inheritance. Most of these failures are that sentence, arriving somewhere nobody looked.