← All posts

API & helpers

Build a live accessibility audit overlay you can inject anywhere

Lighthouse and axe are excellent — and they are also a context switch. You open DevTools, run the audit, read the report, jump back to the page. For a fast first pass while you build, you want the issues drawn on the page itself: a red outline around every image with no alt text, a badge over every unlabeled input. This article builds exactly that as a JavaScript rule you can inject into any site with JustZix.

What this overlay is — and what it is not

This is a first-pass smoke test. It catches the cheap, common, mechanical mistakes — the ones that account for a large share of real accessibility failures — and it shows them visually so you cannot miss them. It is not a replacement for axe-core or Lighthouse: it does not test ARIA semantics deeply, focus order, or screen-reader behavior. Think of it as the linter you run constantly, not the full audit you run before shipping.

The whole thing is one JS rule. Inject it, glance at the page, fix what lights up, re-inject.

The skeleton

We will collect issues into an array, draw an outline plus a badge for each, and show a summary counter. Start with the scaffolding.

(() => {
  // Clean up a previous run
  document.querySelectorAll('.jz-a11y').forEach(n => n.remove());

  const issues = [];

  function flag(el, label, color) {
    if (!el || !el.getBoundingClientRect) return;
    const r = el.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) return; // skip hidden
    issues.push({ label });

    const box = document.createElement('div');
    box.className = 'jz-a11y';
    Object.assign(box.style, {
      position: 'absolute',
      left: (r.left + scrollX) + 'px',
      top: (r.top + scrollY) + 'px',
      width: r.width + 'px',
      height: r.height + 'px',
      outline: '2px solid ' + color,
      background: color + '22',
      zIndex: 2147483000,
      pointerEvents: 'none',
      boxSizing: 'border-box',
    });

    const tag = document.createElement('span');
    tag.textContent = label;
    Object.assign(tag.style, {
      position: 'absolute', left: 0, top: '-18px',
      font: '11px/1.4 system-ui, sans-serif',
      background: color, color: '#fff',
      padding: '1px 5px', whiteSpace: 'nowrap',
    });
    box.appendChild(tag);
    document.body.appendChild(box);
  }

One flag() call per problem element. It records the issue and paints an absolutely positioned box over the element with a labeled badge. Everything carries the jz-a11y class so the next run can wipe it.

Check 1 — images missing alt

An <img> with no alt attribute at all is a failure; alt="" is valid (it marks the image as decorative), so we only flag a genuinely missing attribute.

  document.querySelectorAll('img').forEach(img => {
    if (!img.hasAttribute('alt')) {
      flag(img, 'img: no alt', '#e11d48');
    }
  });

Check 2 — form inputs without a label

An input needs an accessible name. That can come from a wrapping or associated <label>, an aria-label, or aria-labelledby. If none is present, the field is unusable with a screen reader.

  const fields = 'input:not([type=hidden]):not([type=submit])'
    + ':not([type=button]), select, textarea';

  document.querySelectorAll(fields).forEach(el => {
    const byFor = el.id &&
      document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
    const wrapped = el.closest('label');
    const aria = el.getAttribute('aria-label')
      || el.getAttribute('aria-labelledby')
      || el.getAttribute('title');

    if (!byFor && !wrapped && !aria) {
      flag(el, 'input: no label', '#f59e0b');
    }
  });

Check 3 — empty links and buttons

A link or button with no text content and no accessible label is announced as just "link" or "button". This is extremely common with icon-only buttons.

  document.querySelectorAll('a, button').forEach(el => {
    const text = (el.textContent || '').trim();
    const aria = el.getAttribute('aria-label')
      || el.getAttribute('title');
    const img = el.querySelector('img[alt]:not([alt=""])');
    const hasName = text || aria || img;

    if (!hasName) {
      const what = el.tagName === 'A' ? 'link' : 'button';
      flag(el, 'empty ' + what, '#8b5cf6');
    }
  });

Check 4 — low-contrast text

Full WCAG contrast math is involved, but a relative-luminance check catches the worst offenders. Compute luminance for the text color and the resolved background, then take the contrast ratio.

  function lum(rgb) {
    const c = rgb.map(v => {
      v /= 255;
      return v <= 0.03928
        ? v / 12.92
        : Math.pow((v + 0.055) / 1.055, 2.4);
    });
    return 0.2126*c[0] + 0.7152*c[1] + 0.0722*c[2];
  }
  function parse(str) {
    const m = str.match(/\d+(\.\d+)?/g);
    return m ? m.slice(0, 3).map(Number) : null;
  }
  function bgOf(el) {
    let n = el;
    while (n) {
      const bg = getComputedStyle(n).backgroundColor;
      if (bg && !/rgba?\(0, 0, 0, 0\)|transparent/.test(bg)) {
        return parse(bg);
      }
      n = n.parentElement;
    }
    return [255, 255, 255];
  }

  document.querySelectorAll('p, span, a, li, h1, h2, h3, h4, td')
    .forEach(el => {
      if (!(el.textContent || '').trim()) return;
      const fg = parse(getComputedStyle(el).color);
      const bg = bgOf(el);
      if (!fg || !bg) return;
      const l1 = lum(fg) + 0.05, l2 = lum(bg) + 0.05;
      const ratio = l1 > l2 ? l1 / l2 : l2 / l1;
      if (ratio < 4.5) {
        flag(el, 'contrast ' + ratio.toFixed(1) + ':1', '#0ea5e9');
      }
    });

The 4.5:1 threshold is the WCAG AA minimum for normal-size text. Large text gets a pass at 3:1, so expect a few false positives on big headings — eyeball those.

Check 5 — heading-order jumps and missing lang

Headings should not skip levels — an <h2> followed straight by an <h4> breaks the document outline. And the root <html> needs a lang so screen readers pick the right pronunciation.

  let prev = 0;
  document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(h => {
    const level = +h.tagName[1];
    if (prev && level > prev + 1) {
      flag(h, 'jump h' + prev + '→h' + level, '#db2777');
    }
    prev = level;
  });

  if (!document.documentElement.getAttribute('lang')) {
    issues.push({ label: 'html: no lang' });
  }

The summary counter

Finally, a fixed badge in the corner with the total. It is the at-a-glance score; the outlines are the detail.

  const sum = document.createElement('div');
  sum.className = 'jz-a11y';
  sum.textContent = issues.length
    ? issues.length + ' a11y issue(s)'
    : 'No quick a11y issues found';
  Object.assign(sum.style, {
    position: 'fixed', right: '12px', bottom: '12px',
    font: '13px/1.5 system-ui, sans-serif',
    background: issues.length ? '#e11d48' : '#16a34a',
    color: '#fff', padding: '6px 12px', borderRadius: '6px',
    zIndex: 2147483600, pointerEvents: 'none',
  });
  document.body.appendChild(sum);

  console.table(issues);
})();

The console.table(issues) at the end also dumps a tidy list to the Output Console, so you have both the visual overlay and a copyable summary.

How to use it day to day

See also

A live audit overlay turns accessibility from a checklist you forget into something you cannot ignore. Install JustZix, paste the script, and let the page tell you what is wrong.

Rate this post

No ratings yet — be the first.

Try it yourself

Install JustZix and paste any snippet from this article. Two minutes from zero to a working rule across all your devices.

Get JustZix

Features · How it works · Examples · Use cases