Standalone

When to use it

For a manual check against a page open in a real browser, a bookmarklet, or any other context with no automation driver and no build step to reach for.

Usage

@surea11y/core ships surea11y.browser.js at the package root, a bundle generated from the same rule sources as the Node package. Loading it directly defines one global, a11ycore:

<script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
<script>
const result = a11ycore.runa11yCoreInPage(location.href, null, {}, null);
console.log(result.checksResults.filter((r) => r.outcome === 'fail'));
</script>

a11ycore.runa11yCoreInPage is the exact same function the bindings call under the hood — the bundle exists only to solve loading it without require/a module system, not to add a separate API surface. The arguments are the same as anywhere else. A scan scoped to one region, limited to WCAG 2.2 A and AA, with two noisy widgets left out:

<script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
<script>
const result = a11ycore.runa11yCoreInPage(
location.href,
'#main', // contextSelector: scan only this region
{
excludeSelectors: ['#cookie-banner', '.intercom-launcher'],
contrast: { mode: 'auditorAssist' } // trade some false-positive protection for more findings
},
{ wcag: { version: '2.2', level: 'AA' } } // runOnly
);
</script>

The bundle carries English only. Every other locale ships beside it as its own file — load one after the bundle and that language becomes available:

<script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
<script src="node_modules/@surea11y/core/surea11y.i18n.de.js"></script>
<script>
const result = a11ycore.runa11yCoreInPage(location.href, null, { locale: 'de' }, null);
</script>

Ask for a locale whose file you haven't loaded and you get English, not an error — see Locales.

The bundle also loads as a module: import a11ycore from '@surea11y/core/browser', a named import of runa11yCoreInPage, and require() all get its API, and the global is still defined.

Deliberately excluded from the bundle: runa11yCoreAcrossFrames/a11yCoreEnableFrameResponder (cross-frame scanning needs the embedded frame to load the engine and opt in too — not a fit for a single dropped-in script tag). Use the npm package directly if you need it — see INTEGRATION.md.

require('@surea11y/core') directly, without any binding, is the other standalone path — this is Pattern 1/Pattern 2 in INTEGRATION.md: jsdom in Node with no real browser, or driving Puppeteer/Playwright yourself instead of going through a binding. For tests, runa11yCoreOnHtml(html, options) from @surea11y/core/testing scans an HTML string in jsdom and sets up the document for you; since core 1.12 its contentType option parses an SVG or XHTML document as what it is.

From the browser console

For a scan by hand, the bundle has a few helpers. Load it into any open page from the browser's console (the Console tab of the developer tools), then scan, list the findings, box them on the page, or open the HTML report:

// Pasted into the console of the page to check.
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/@surea11y/core/surea11y.browser.js';
document.head.append(s);
 
// Once it has loaded:
a11ycore.version; // the engine's version
const result = await a11ycore.run(); // scan the page
a11ycore.table(result); // the findings, worst first
a11ycore.highlight(result); // box each one on the page
await a11ycore.openReport(result); // the HTML report, in a new tab
await a11ycore.inspect('headings'); // what a person checks by hand

run(options) takes the engine options plus two of its own, context (a selector, to scan only part of the page) and runOnly, and fetches the style sheets the page can't read itself before it scans. table() prints one row per finding: failures first, then the questions that lean fail, those that lean pass, and those only a person can judge. highlight() draws a box over each element a fail (solid red) or cantTell (dashed amber) names, in a layer of its own that leaves the page's elements untouched; clearHighlights() removes it. inspect() draws what a person reviewing the page checks by hand, and judges nothing: 'headings', 'landmarks', 'names' or 'focus-order'. openReport() fetches surea11y.report.js from beside the bundle the first time, and the report shows how to check each question. A page's Content Security Policy can block a script from a CDN: load the files from the page's own origin then, or scan with a driver. Every option is in the browser console section of INTEGRATION.md, the integration guide on GitHub.

Packs in a page

A pack is prepared in Node, so it reaches a page as a script. For a page that loads its scripts as files (a <script> tag, a bookmarklet, an extension), build one file in Node: buildBrowserBundle({ packs }) from @surea11y/core/pack is the bundle with the packs registered after it. Then name the packs in engineOptions.packs as name@version. The rules' code is written into the script as functions, so it runs where a Content Security Policy forbids eval.

// In Node, once per release of core or of a pack.
const fs = require('node:fs');
const { buildBrowserBundle } = require('@surea11y/core/pack');
const acmePolicy = require('./acme-policy-pack');
fs.writeFileSync('surea11y-acme.js', buildBrowserBundle({ packs: [acmePolicy] }));
<script src="surea11y-acme.js"></script>
<script>
const result = a11ycore.runa11yCoreInPage(location.href, null, {
packs: ['@acme/a11y-policy@1.0.0'],
profile: 'acme-web-2026'
}, null);
</script>

A pack's script runs only with the core that wrote it: it carries the catalog core and the packs make together, checked in Node. A page whose bundle is another version of core runs the scan without the packs and lists each in skippedPacks, naming the two versions. One file holds both, so they can't fall out of step. Build it again when you update core or a pack, and update the name@version the page names.

Where core's bundle is already on the page, write packScript(packs) to a file with the same core, and load it after the bundle (and into every frame you scan):

// In Node, with the core whose bundle the page loads.
const fs = require('node:fs');
const { packScript } = require('@surea11y/core/pack');
const acmePolicy = require('./acme-policy-pack');
fs.writeFileSync('acme-pack.js', packScript([acmePolicy]));
<script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
<script src="acme-pack.js"></script>

The page runs exactly the set of packs one packScript call registered, with the core it was prepared with: pass every pack a scan needs to one call. Named packs the page doesn't run are listed in skippedPacks with the reason. The bindings do all of this for you with .withPacks().

Waiting for the page before a scan

A scan reads the page as it is at that moment. On a page that has just opened, web fonts may still be loading and images may not have arrived, so the same page scanned a second later can give another result. The scan records this in engine.environment.fonts and .images (see the output schema), but the better fix is not to scan until the page has settled.

waitForPageReady(options) does that waiting, and the bundle puts it on the same global. The scan never calls it itself; call it just before you scan. It waits for the window's load event, for document.fonts.ready, and for every image still loading (except loading="lazy" ones), for at most timeoutMs (default 5000). It never rejects: when the time runs out it resolves anyway with ready: false and says what was still pending. Whether to scan anyway, wait longer or stop is your choice; if you scan, keep what was pending with the result.

const ready = await a11ycore.waitForPageReady({ timeoutMs: 3000 });
const result = a11ycore.runa11yCoreInPage(location.href, null, {}, null);

A driver you run yourself can send it into the page the same way as runa11yCoreInPage: page.evaluate(waitForPageReady, { timeoutMs: 5000 }). In jsdom there is nothing to wait for, so it resolves at once with ready: true. The other options (quietMs, document) and the full return value are in INTEGRATION.md, the integration guide on GitHub.

Style sheets from another origin

A page's script can't read the rules of a style sheet served from another origin (a CDN, a fonts service), so a rule that looks for something in the CSS, such as css-orientation-lock, can't tell whether it's there, and asks. Give the scan their text in engineOptions.styleSheetTexts ([{ href, text }]) and css-orientation-lock, css-hidden-focus and css-focus-indicator-suppressed read it. On the bundle, runa11yCoreInPageAsync takes the same four arguments as runa11yCoreInPage, fetches the sheets whose server shares them (CORS) with preloadStyleSheets(), then scans:

const result = await a11ycore.runa11yCoreInPageAsync(location.href, null, {}, null);

A sheet whose server doesn't share it can't be read from the page at all. A tool driving the browser can fetch it from outside the page, where no CORS applies, and pass it in the same option; the bindings do.

A time limit on a scan

A scan runs in one go inside the page, and nothing in the page can stop it once it has started: a page getter or a rule that never returns blocks that tab for good. Limit the time from where you start the scan, and give up on the tab when the limit passes: your time limit stops the wait, not the script running in the page. 60 seconds is far above what a normal page takes, so it only catches the broken ones:

// Playwright or Puppeteer: page.evaluate has no time limit of its own.
async function scanWithin(page, ms, scan) {
let timer;
const late = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`the scan took longer than ${ms} ms`)), ms);
});
try {
return await Promise.race([scan(), late]);
} catch (err) {
// The tab may still be busy with the scan: close it, and use a new one.
await page.close().catch(() => {});
throw err;
} finally {
clearTimeout(timer);
}
}
 
const fs = require('node:fs');
await page.evaluate(fs.readFileSync(require.resolve('@surea11y/core/browser'), 'utf8'));
const result = await scanWithin(page, 60_000, () =>
page.evaluate(() => window.a11ycore.runa11yCoreInPage(location.href, null, {}, null))
);

In jsdom the scan runs on Node's own thread, so a timer there can't fire until it ends: run the scan in a worker thread or a child process, and end that when the limit passes. A CI job's own time limit is the last resort for both.

Web components with private internals

A custom element can name itself, set its states and give itself a role through ElementInternals. Many keep their internals in a private field, where no script can read them back, so a scan asks about such an element (cantTell) instead of judging it, and doesn't see a role set that way at all.

internalsHookScript(), from @surea11y/core (not the bundle), returns a script that records each element's internals as the page attaches them, so the scan can read them. It is opt-in: core never installs it. Install it before the page's own scripts, in every frame you scan: with your driver's init-script API, or on a page you build yourself as the first <script> in <head>. In a jsdom setup file, which runs after the document has loaded but before any component is defined, pass { early: true }:

const { internalsHookScript } = require('@surea11y/core');
 
// Playwright
await page.addInitScript({ content: internalsHookScript() });
 
// Puppeteer
await page.evaluateOnNewDocument(internalsHookScript());
 
// jest.setup.js or vitest.setup.js, before any component is imported
window.eval(internalsHookScript({ early: true }));

The result says whether it was there, in engine.environment.internalsHook: 'early' when it was installed before the page's scripts, 'late' after them, when it only sees what the page attaches from then on. Any script in the page can read what the hook records, so a component's private internals are no longer private: install it for test and audit runs, not in pages you serve to users. Every other way to install it, and what a page can notice, is in INTEGRATION.md.

Click handlers and live regions

Two more opt-in hooks, from core 1.14, install the same way as the internals hook, before the page's own scripts. listenerHookScript() records the click, key, focus, mouse and paste listeners a page adds from script, so the keyboard rules judge them: a <div role="button"> with a click listener and no key listener anywhere a key event from it goes fails (CLICK_HANDLER_NO_KEY_LISTENER). liveRegionHookScript() records what live regions are given: live-region-announcements then asks about a region added with its text already in it, or shown and filled at once, which screen readers commonly don't announce, and getAnnouncements() returns what the page made available to announce:

const { listenerHookScript, liveRegionHookScript, getAnnouncements } = require('@surea11y/core');
 
// Playwright: before the page's own scripts
await page.addInitScript({ content: listenerHookScript() });
await page.addInitScript({ content: liveRegionHookScript() });
await page.goto('https://example.com/settings');
 
await page.click('text=Save');
const said = await page.evaluate(getAnnouncements, { selectors: true });
// [{ text: 'Saved', politeness: 'polite', time: 1234.5, order: 7, behindModal: false, selector: '#status' }]

engine.environment.listenerHook and .liveRegionHook say whether a scan saw them. Only a listener hook installed before the page's scripts ('early') proves a listener is missing; installed later, the rules only ask. On the bundle, a11ycore.getAnnouncements() reads them in the page. Like the internals hook, both keep what they record where any script on the page can read it: install them for test and audit runs. In a jsdom setup file, pass { early: true }, and for the listener hook run one selector first (document.querySelector('*')), since jsdom adds key listeners of its own the first time a selector runs. A jsdom test that stands in for showModal() by setting open and aria-modal="true" on a dialog installs the live-region hook with { early: true, assumeModal: true } and scans with engineOptions.assumeModal: true (core 1.16.0 and later).

Playwright's setContent() is no navigation. It writes the markup into the page's current document, which has already loaded, so an init script doesn't run while that document is loading. One added to the context before the page was opened runs in the page's first, blank document: the hook is there when setContent() writes the page, but it is recorded as 'late', and click-handler-keyboard-operable asks where an early hook would have failed the element. One added to a page that is already open waits for its next document, and setContent() doesn't make one, so the scan runs with no hook. For early capture, load the page with a real navigation: page.goto() to a server or a file:// URL, or page.route() to answer a URL with the markup. The same holds for the internals hook:

await context.addInitScript({ content: listenerHookScript() });
const page = await context.newPage();
await page.route('https://test.local/', (route) =>
route.fulfill({ body: html, contentType: 'text/html' })
);
await page.goto('https://test.local/'); // listenerHook: 'early'

The listener hook and live-region hook sections of INTEGRATION.md, the integration guide on GitHub, have the rest.

Leans and steps for questions

Since core 1.14, a cantTell occurrence can carry a lean: which way the page's evidence points (toward: 'fail' or 'pass'), why, and what could make it wrong. The outcome stays cantTell. Each question of a core rule also carries howToCheck, the steps a person follows to answer it, in the scan's locale. They're added in Node, not by the engine in the page: runDomRulesInPage adds them, and for a result that comes back from a page, addHowToCheck(result) returns a copy with them. leaningFailures(result) lists the findings that lean fail, for a gate of your own:

const { runa11yCoreInPage, addHowToCheck, leaningFailures } = require('@surea11y/core');
 
const raw = await page.evaluate(runa11yCoreInPage, page.url(), null, {}, null);
const result = addHowToCheck(raw); // a copy, with each question's steps
const likelyFailures = leaningFailures(result);

Whether a finding leans, and its texts, may change in a minor release of core; the shape of lean is stable. See the output schema for both fields.

Procedures in a browser

Since core 1.15, the test procedures, how a person assesses each criterion by hand, come to code that runs in a browser too, from @surea11y/core/procedures: it needs no file system and reaches none of the rules, so a browser extension or a page of your own can show them, or open a worksheet, with the dictionaries it imports:

import { procedureChecklistFrom, renderProceduresFrom } from '@surea11y/core/procedures';
import en from '@surea11y/core/procedures/en';
import fr from '@surea11y/core/procedures/fr';
 
const dictionaries = { en, fr };
const checklist = procedureChecklistFrom(dictionaries, { levels: ['A', 'AA'], locale: 'fr' });
const html = renderProceduresFrom(dictionaries, checklist);
window.open(URL.createObjectURL(new Blob([html], { type: 'text/html' })));

Knowing what a scan covers

A tool that reports by WCAG criterion and lets its users pick checks can say which target the scan is for: engineOptions.wcagLevel, beside wcagVersion, leaves out criteria above that level without changing which checks run. getRulesForRunOnly(runOnly, engineOptions) lists the criteria a scan with that selection reports, before you run it, using the scan's own logic:

const { getRulesForRunOnly, runDomRulesInPage } = require('@surea11y/core');
 
const engineOptions = { wcagVersion: '2.2', wcagLevel: 'AA' };
const runOnly = { includeRuleIds: ['contrast-computable'] };
 
getRulesForRunOnly(runOnly, engineOptions).map((r) => r.id); // before the scan
const result = runDomRulesInPage(url, null, engineOptions, runOnly);

A criterion only some of whose checks ran comes back cantTell, with each check that didn't run marked notSelected: true: show it as partly checked, never as passed. Since core 1.14 no criterion is reported as passing at all: one passes only when its rules check everything it asks, and none is listed as checked in full yet. Building a tool of your own on core (a CLI, a CI integration, a dashboard, a conformance-report generator) is covered in BUILDING_ON_CORE.md on GitHub: what you can rely on, running scans, choosing checks, presenting results honestly and comparing runs.