axe-core Advanced Configuration: Custom Rules, Rule Overrides, and Selective Auditing

axe-core Advanced Configuration: Custom Rules, Rule Overrides, and Selective Auditing

Most teams use axe-core with default settings: run it against a page, count violations, fail the build. That works until your design system produces dozens of false positives, or your app has accessibility requirements that no default rule covers. This guide covers the axe-core internals you need to write custom rules, suppress false positives cleanly, and run precise audits on complex component trees.

axe-core Architecture

Understanding axe-core's internal model lets you configure it correctly.

Rules → each rule tests one specific accessibility requirement (e.g., color-contrast, aria-required-attr) Checks → each rule is composed of one or more checks. Checks do the actual DOM evaluation. Context → what to scan: element selectors, include/exclude lists, iframes Options → which rules to run, rule overrides, reporter format

A rule has:

  • id: unique name
  • selector: CSS selector pre-filter (optimization)
  • tags: WCAG criteria this rule covers
  • all, any, none: arrays of check IDs (check semantics differ)
  • enabled: boolean, can be overridden per-run

Context Configuration

Include/exclude selectors

import AxeBuilder from '@axe-core/playwright';

// Only audit a specific component
const results = await new AxeBuilder({ page })
  .include('#main-content')
  .analyze();

// Audit everything except third-party widgets
const results = await new AxeBuilder({ page })
  .exclude('#intercom-frame')
  .exclude('.third-party-chat')
  .exclude('[data-noaudit]')
  .analyze();

// Audit multiple sections
const results = await new AxeBuilder({ page })
  .include('#header')
  .include('#main')
  .include('#footer')
  .analyze();

iframe handling

By default, axe-core audits iframes on the same origin. Control this explicitly:

// axe-core direct (not playwright wrapper)
const results = await axe.run(
  {
    include: [['#main']],
    // Explicitly include a same-origin iframe
    include: [['#main'], ['#my-iframe', '#form-inside-iframe']]
  },
  {
    iframes: true  // scan same-origin iframes
  }
);

Dynamic context from test data

// Audit only rendered items in a list
async function auditDataTable(page, tableSelector) {
  const rowCount = await page.locator(`${tableSelector} tbody tr`).count();

  if (rowCount === 0) {
    console.log('Empty table — skipping data row audit');
    return { violations: [] };
  }

  return new AxeBuilder({ page })
    .include(tableSelector)
    .withRules(['td-headers-attr', 'th-has-data-cells', 'scope-attr-valid'])
    .analyze();
}

Rule Configuration

Disabling rules

Disable a rule globally when it produces false positives in your environment:

// Playwright
const results = await new AxeBuilder({ page })
  .disableRules(['color-contrast'])  // if your CI is headless with different rendering
  .analyze();

// axe-core directly
const results = await axe.run(document, {
  rules: {
    'color-contrast': { enabled: false },
    'duplicate-id': { enabled: false }  // if server-rendered HTML with known dupe IDs
  }
});

Rule options / overrides

Some rules accept options that change their thresholds:

// Relax color contrast for large text components
const results = await axe.run(document, {
  rules: {
    'color-contrast': {
      enabled: true,
      options: {
        // Override contrast ratio thresholds
        noScroll: true,           // don't scroll before checking
        cssPropertyFilter: []     // include CSS-hidden elements
      }
    }
  }
});

Running only specific rules

// Run only the rules that matter for a form component
const formResults = await new AxeBuilder({ page })
  .include('#contact-form')
  .withRules([
    'label',
    'label-content-name-mismatch',
    'aria-required-attr',
    'aria-required-children',
    'aria-valid-attr-value',
    'autocomplete-valid',
    'form-field-multiple-labels'
  ])
  .analyze();

Writing Custom Rules

Custom rules cover requirements that default axe doesn't test. Common use cases: design system tokens, brand-specific requirements, internal ARIA patterns.

Rule anatomy

axe.configure({
  rules: [{
    id: 'my-custom-rule',
    selector: 'button',        // CSS pre-filter
    tags: ['custom', 'brand'], // arbitrary tags for filtering
    metadata: {
      url: 'https://your-docs/accessibility/buttons',
      description: 'Buttons must have a data-analytics attribute',
      help: 'All buttons need data-analytics for event tracking',
      helpUrl: 'https://your-docs/accessibility/buttons'
    },
    all: [],
    any: ['button-has-analytics'],
    none: []
  }],
  checks: [{
    id: 'button-has-analytics',
    evaluate: function(node, options, virtualNode, context) {
      return node.hasAttribute('data-analytics');
    },
    metadata: {
      impact: 'moderate',
      messages: {
        pass: 'Button has data-analytics attribute',
        fail: {
          missingAttribute: 'Button is missing data-analytics attribute'
        }
      }
    }
  }]
});

Check return values

The evaluate function in a check returns:

  • true → check passes
  • false → check fails with the default fail message
  • this.data({ messageKey: 'myKey', ... }) then return false → check fails with a specific message key
checks: [{
  id: 'heading-level-sequence',
  evaluate: function(node, options) {
    const level = parseInt(node.tagName.replace('H', ''), 10);

    // Get previous heading
    const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'));
    const idx = headings.indexOf(node);

    if (idx === 0) return true; // first heading, always valid

    const prevLevel = parseInt(headings[idx - 1].tagName.replace('H', ''), 10);

    if (level > prevLevel + 1) {
      this.data({
        messageKey: 'skippedLevel',
        prevLevel,
        currentLevel: level
      });
      return false;
    }

    return true;
  },
  metadata: {
    impact: 'moderate',
    messages: {
      pass: 'Heading level follows sequence',
      fail: {
        skippedLevel: 'Heading skips from h${data.prevLevel} to h${data.currentLevel}'
      }
    }
  }
}]

Using virtualNode for performance

The virtualNode parameter gives you a lightweight representation of the node and its ancestors/children without repeated DOM queries:

evaluate: function(node, options, virtualNode) {
  // Get children without DOM query
  const children = virtualNode.children;

  // Walk ancestors
  let ancestor = virtualNode.parent;
  while (ancestor) {
    if (ancestor.actualNode.getAttribute('role') === 'listbox') {
      return true; // found containing listbox
    }
    ancestor = ancestor.parent;
  }

  this.data({ messageKey: 'noListboxAncestor' });
  return false;
}

Custom checks with options

Pass configuration to checks at rule evaluation time:

axe.configure({
  checks: [{
    id: 'min-touch-target',
    evaluate: function(node, options) {
      const rect = node.getBoundingClientRect();
      const minSize = options.minSize || 44;  // default 44px (iOS HIG)

      if (rect.width < minSize || rect.height < minSize) {
        this.data({
          messageKey: 'tooSmall',
          width: Math.round(rect.width),
          height: Math.round(rect.height),
          required: minSize
        });
        return false;
      }
      return true;
    },
    metadata: {
      impact: 'serious',
      messages: {
        pass: 'Touch target meets minimum size',
        fail: {
          tooSmall: 'Touch target is ${data.width}x${data.height}px, minimum is ${data.required}x${data.required}px'
        }
      }
    }
  }],
  rules: [{
    id: 'touch-target-size',
    selector: 'a, button, [role="button"], [role="link"], input, select',
    tags: ['mobile', 'touch'],
    metadata: {
      description: 'Interactive elements must meet minimum touch target size',
      help: 'Ensure touch targets are at least 44x44px for mobile usability',
      helpUrl: 'https://developer.apple.com/design/human-interface-guidelines/buttons'
    },
    all: [],
    any: ['min-touch-target'],
    none: []
  }]
});

// Use with custom minSize
const results = await axe.run(document, {
  rules: { 'touch-target-size': { enabled: true } },
  checks: {
    'min-touch-target': {
      options: { minSize: 44 }  // iOS HIG
    }
  }
});

Registering Custom Rules in Different Integrations

jest-axe

import { axe, toHaveNoViolations, configureAxe } from 'jest-axe';

// Configure once in setup file
const customAxe = configureAxe({
  rules: [{
    id: 'data-analytics-on-buttons',
    // ... rule definition
  }]
});

expect.extend(toHaveNoViolations);

test('Button component', async () => {
  const { container } = render(<Button onClick={fn}>Submit</Button>);
  const results = await customAxe(container, {
    runOnly: ['wcag2aa', 'custom']
  });
  expect(results).toHaveNoViolations();
});

Playwright persistent configuration

// fixtures/axe.ts
import { test as base } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { customRules, customChecks } from './axe-custom-rules';

type AxeFixture = {
  makeAxeBuilder: () => AxeBuilder;
};

export const test = base.extend<AxeFixture>({
  makeAxeBuilder: async ({ page }, use) => {
    const makeAxeBuilder = () => new AxeBuilder({ page })
      .configure({
        rules: customRules,
        checks: customChecks
      })
      .withTags(['wcag2aa', 'wcag22aa', 'custom']);

    await use(makeAxeBuilder);
  }
});

// In test file:
import { test } from './fixtures/axe';

test('homepage accessibility', async ({ page, makeAxeBuilder }) => {
  await page.goto('/');
  const results = await makeAxeBuilder().analyze();
  expect(results.violations).toEqual([]);
});

Managing False Positives

False positives fall into categories: rendering environment artifacts, known third-party violations, design exceptions with approved rationale.

Element-level suppression

<!-- In HTML: suppress specific checks on specific elements -->
<div class="legacy-widget" data-axe-disabled="color-contrast">
  <!-- Third-party widget with non-negotiable contrast -->
</div>
// In axe-core config:
const results = await axe.run(document, {
  elementRef: true  // includes element references in results
});

// Or use CSS attribute to suppress at selector level
axe.configure({
  rules: [{
    id: 'color-contrast',
    // Exclude elements with data-axe-disabled="color-contrast"
    selector: ':not([data-axe-disabled~="color-contrast"])'
  }]
});

Violation allowlist with documentation

Maintain an allowlist with justification:

// axe-allowlist.js
export const ALLOWLISTED_VIOLATIONS = [
  {
    ruleId: 'color-contrast',
    selector: '.legacy-chart-tooltip',
    reason: 'Third-party chart library (v2.3.1). Vendor issue filed: github.com/...',
    expires: '2024-06-01',
    approvedBy: 'a11y-team'
  },
  {
    ruleId: 'duplicate-id',
    selector: '#server-rendered-component',
    reason: 'SSR limitation in legacy module. Tracked in JIRA-1234.',
    expires: null  // permanent exception
  }
];

// Filter function
function filterAllowlisted(violations, page) {
  return violations.filter(violation => {
    return !ALLOWLISTED_VIOLATIONS.some(entry => {
      if (entry.ruleId !== violation.id) return false;
      if (entry.expires && new Date(entry.expires) < new Date()) {
        console.warn(`EXPIRED allowlist entry: ${entry.ruleId} for ${entry.selector}`);
        return false;
      }
      return violation.nodes.every(node => node.target[0]?.includes(entry.selector));
    });
  });
}

Audit Result Processing

Severity-based reporting

const SEVERITY_ORDER = ['critical', 'serious', 'moderate', 'minor'];

function processResults(results, options = {}) {
  const { failThreshold = 'serious', warnThreshold = 'moderate' } = options;

  const failIdx = SEVERITY_ORDER.indexOf(failThreshold);
  const warnIdx = SEVERITY_ORDER.indexOf(warnThreshold);

  const failures = results.violations.filter(v =>
    SEVERITY_ORDER.indexOf(v.impact) <= failIdx
  );
  const warnings = results.violations.filter(v => {
    const idx = SEVERITY_ORDER.indexOf(v.impact);
    return idx > failIdx && idx <= warnIdx;
  });

  return { failures, warnings, passes: results.passes };
}

const { failures, warnings } = processResults(auditResults, {
  failThreshold: 'serious',
  warnThreshold: 'moderate'
});

if (failures.length > 0) {
  console.error(`${failures.length} violations fail the build`);
  process.exit(1);
}

warnings.forEach(w => console.warn(`WARN: ${w.id} (${w.impact})`));

SARIF output for GitHub code scanning

import { writeSarif } from 'axe-sarif-converter';
import { writeFileSync } from 'fs';

const sarif = writeSarif(results);
writeFileSync('axe-results.sarif', JSON.stringify(sarif));
# In GitHub Actions:
- name: Upload SARIF
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: axe-results.sarif
    category: accessibility

Performance Optimization

axe-core scanning is synchronous and CPU-intensive. For large pages:

// Limit scan to visible viewport
const results = await axe.run({
  include: [['body']],
}, {
  runOnly: ['wcag2aa'],
  // Only check elements in viewport
  elementRef: false,
  // Disable expensive rules for fast checks
  rules: {
    'color-contrast': { enabled: false }  // most expensive rule
  }
});

// Run expensive rules separately on demand
const contrastResults = await axe.run(document, {
  rules: { 'color-contrast': { enabled: true } },
  runOnly: ['color-contrast']
});

Parallel page auditing

async function auditAllPages(urls) {
  const browser = await chromium.launch();

  // Audit pages in parallel (limit concurrency)
  const CONCURRENCY = 4;
  const results = [];

  for (let i = 0; i < urls.length; i += CONCURRENCY) {
    const batch = urls.slice(i, i + CONCURRENCY);
    const batchResults = await Promise.all(
      batch.map(async url => {
        const page = await browser.newPage();
        await page.goto(url);
        await page.waitForLoadState('networkidle');
        const audit = await new AxeBuilder({ page })
          .withTags(['wcag2aa', 'wcag22aa'])
          .analyze();
        await page.close();
        return { url, ...audit };
      })
    );
    results.push(...batchResults);
  }

  await browser.close();
  return results;
}

Custom rules and precise context configuration are what separate axe-core as a quality gate from axe-core as a rubber stamp. Write rules for your design system's specific requirements, maintain an allowlist with expiry dates and justifications, and use tags to group rules by context (mobile, SPA navigation, forms). The default configuration gets you started — advanced configuration makes it maintainable.

Read more

Start now free