A modal that is not announced as a dialog
What it costs you
This full-screen overlay renders a panel with controls in it, but declares no dialog semantics: no role, no aria-modal, no Escape handler and no focus management. A screen reader announces nothing when it opens and keeps reading the page behind it, and a keyboard user has to tab through the covered page to reach the controls — or tabs straight past them into content they can see is obscured.
The defect, and the fix
Both samples are scanned as app/components/ConfirmDialog.tsx.
Our test suite runs both through the scanner on every build: the first must be reported, the second must not.
export function ConfirmDialog({ open, onClose, onConfirm }) {
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white rounded-xl p-6">
<h2>Delete this project?</h2>
<button onClick={onClose}>Cancel</button>
<button onClick={onConfirm}>Delete</button>
</div>
</div>
);
}export function ConfirmDialog({ open, onClose, onConfirm }) {
const ref = useRef(null);
useEffect(() => {
if (!open) return;
ref.current?.focus();
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
ref={ref}
tabIndex={-1}
className="bg-white rounded-xl p-6"
>
<h2 id="confirm-title">Delete this project?</h2>
<button onClick={onClose}>Cancel</button>
<button onClick={onConfirm}>Delete</button>
</div>
</div>
);
}What changed: The panel declares itself a modal dialog, takes focus when it opens and closes on Escape, so a screen reader announces it and a keyboard can reach and leave it.
How to fix it
Put role="dialog" and aria-modal="true" on the panel, point aria-labelledby at its heading, move focus into it when it opens and restore focus when it closes, and close it on Escape. A dialog primitive (Radix, Headless UI) or one shared shell component gets all of that once instead of per modal.
Why this rule doesn't cry wolf
Each clause below exists because it was attacked: someone was asked to find correct code that the rule would flag, and the clause is what stopped it. This is published because a check you cannot audit is a check you have to take on faith.
The fixed+inset-0 tokens must come from the element's OWN className, resolved through cn()/clsx/template literals and never from a string elsewhere in the file — that single clause separates this from a rule that flags this repo's correct LegalModal.tsx. Then: native lowercase or motion.* tags only; must render an element child and contain a focusable one; no role of any kind, no aria-modal and no dialog element anywhere in the subtree; no pointer-events-none, z-0, negative z or responsive hidden; must be gated on an open-ish condition or live in a *Modal/Dialog/Sheet/Drawer/Lightbox/Popup file; and the file must carry no Escape handling, no focus management and no dialog-primitive import. Capped at one finding per file and five per scan, because the worst corpus repo fires 82 times and volume is its own precision problem.
This is not a security finding
QA006 is reported in its own section, separately from security findings. It does not change your security grade, and it does not fail your build unless you pass --fail-on-quality. The security catalogue lives at /rules.
Other Accessibility checks
Check your own code
npx xploitscan scan .Runs on every plan, including free. All 12 quality checks.