Learn to scan web applications for WCAG 2.2 accessibility violations using axe-core, IBM Equal Access, and custom Playwright checks.
View the Project on GitHub devopsabcs-engineering/accessibility-scan-workshop
| Duration | 35 minutes |
| Level | Intermediate |
| Prerequisites | Lab 01 |
By the end of this lab, you will be able to:
Automated engines like axe-core cannot catch every accessibility issue. The scanner includes custom Playwright-based checks for issues that require DOM interaction or visual inspection.
Open the custom checks source file in your editor:
src/lib/scanner/custom-checks.ts
Review the existing checks:
| Check Function | What It Detects | WCAG Criterion |
|---|---|---|
checkAmbiguousLinkText |
Links with vague text like “click here,” “read more,” or “learn more” | 2.4.4 Link Purpose |
checkAriaCurrentPage |
Navigation elements missing aria-current="page" on the active link |
1.3.1 Info and Relationships |
checkEmphasisStrongSemantics |
Presentational use of <b> / <i> instead of semantic <strong> / <em> |
1.3.1 Info and Relationships |
checkDiscountPriceAccessibility |
Prices marked with strikethrough (<del> / <s>) missing screen reader context |
1.1.1 Non-text Content |
checkStickyElementOverlap |
Sticky headers or footers that could overlap content when scrolling | 2.4.11 Focus Not Obscured |
Note the check function pattern. Each function:
Page objectCustomCheckResult or null (null if no violation found)page.evaluate() to query the DOM
You will run a scan that includes custom checks and examine the additional findings.
Scan demo app 001 with the scanner (custom checks run automatically):
npx ts-node src/cli/commands/scan.ts --url http://localhost:8001 --format json --output results/demo-001-custom.json
Open results/demo-001-custom.json and search for findings with the custom- prefix in their rule IDs. These are the custom check results.
You should see findings for:

[!NOTE] Custom checks complement automated engines. axe-core checks
link-name(whether a link has accessible text at all), while the custom checkcheckAmbiguousLinkTextgoes further to flag links that have text but the text is not descriptive enough.
Many accessibility issues only appear during keyboard interaction. You will review how the scanner tests keyboard accessibility.
The demo apps include a deliberate keyboard trap. Demo app 001 contains this JavaScript:
document.addEventListener('keydown', function(e) {
if (e.key === 'Tab') { }
});
This intercepts the Tab key and does nothing, trapping keyboard users on the page.
Additionally, all interactive elements (buttons) are implemented as <div> elements with onclick handlers instead of <button> elements:
<!-- Inaccessible -->
<div class="btn" onclick="bookFlight()">Book Now</div>
<!-- Accessible -->
<button onclick="bookFlight()">Book Now</button>
The scanner’s custom checks can detect some keyboard issues by:
tabindex on non-interactive elements used as controls
[!TIP] For manual keyboard testing, press
Tabto move forward,Shift+Tabto move backward,Enterto activate buttons and links, andSpaceto toggle checkboxes and buttons. Every interactive element should be reachable and operable via keyboard alone.
You will create a custom check to detect <marquee> elements, which are deprecated and cause WCAG 2.3.1 violations.
Open src/lib/scanner/custom-checks.ts in your editor.
Add a new check function before the runCustomChecks function:
async function checkDeprecatedMarquee(page: Page): Promise<CustomCheckResult | null> {
const marquees = await page.evaluate(() => {
const elements = document.querySelectorAll('marquee');
if (elements.length === 0) return null;
return Array.from(elements).map((el) => ({
selector: 'marquee',
html: el.outerHTML.substring(0, 200),
}));
});
if (!marquees) return null;
return {
id: 'custom-deprecated-marquee',
impact: 'serious',
description: 'Page contains deprecated <marquee> elements that cause distracting motion',
help: 'Remove <marquee> elements and use CSS animations with prefers-reduced-motion support instead',
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html',
wcag: ['2.2.2', '2.3.1'],
nodes: marquees.map((m) => ({
target: [m.selector],
html: m.html,
})),
};
}

Add the new check to the runCustomChecks function’s check array:
const checks = [
checkAmbiguousLinkText,
checkAriaCurrentPage,
checkEmphasisStrongSemantics,
checkDiscountPriceAccessibility,
checkStickyElementOverlap,
checkDeprecatedMarquee, // Add this line
];
Save the file.
You will verify that your new custom check detects the <marquee> element in demo app 001.
Run the scanner against demo app 001:
npx ts-node src/cli/commands/scan.ts --url http://localhost:8001 --format json --output results/demo-001-marquee.json
Search the output for custom-deprecated-marquee:
grep "custom-deprecated-marquee" results/demo-001-marquee.json
On PowerShell:
Select-String -Path results/demo-001-marquee.json -Pattern "custom-deprecated-marquee"
The check should detect the <marquee> element that demo app 001 uses for its scrolling banner.

[!WARNING] Revert your changes to
custom-checks.tsafter this exercise if you do not want to keep the custom check, or commit the change to your fork. The remaining labs use the original scanner code.
Before proceeding, verify:
custom-checks.ts<marquee> elementsProceed to Lab 05: SARIF Output and GitHub Security Tab.