Catch accessibility issues while building Jetpack Compose UIs - including rendered text-contrast problems that are not available from the Compose semantics tree alone.
ComposeA11yScanner is a debug-only runtime scanner that finds accessibility issues in Jetpack Compose and highlights them directly on the rendered UI. Inspect the affected element, understand the problem, and see a suggested fix without leaving the app.
- Immediate visual feedback - issues are outlined where they occur on the screen.
- Semantics and rendered analysis - rules inspect Compose semantics, while text contrast is estimated from a captured Compose host.
- Actionable guidance - every finding includes its severity, WCAG reference, and a suggested fix.
- Minimal setup - add one debug dependency; AndroidX Startup handles installation.
- Debug-only integration -
debugImplementationkeeps the scanner out of release builds. - Extensible rules - use the bundled rules or add checks for your own accessibility standards.
- Added
TextContrastRulewith conservative screenshot-based foreground and background analysis. - Improved scanning across Fragment navigation and Compose destination changes.
- Improved Compose host selection, screen-readiness detection, and stale-result invalidation.
- Reduced false positives and false negatives involving merged semantics, lazy layouts, off-screen nodes, repeated descriptions, rich text, and overlapping touch targets.
- Preserved source compatibility with 2.0.0; no public API was removed.
Because rendered text contrast is now checked by default, 2.1.0 may report valid warnings that earlier versions could not detect.
Read the 2.1.0 release notes or view the full changelog.
- What's new in 2.1.0
- Quick start
- Configuration
- Built-in rules
- Text contrast and known limitations
- Custom rules
- Architecture
- Support and contributions
- Featured in
ComposeA11yScanner supports Android API 24 and newer. Add JitPack to dependency resolution and add the scanner to the debug variant only:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://jitpack.io")
}
}
// app/build.gradle.kts
dependencies {
debugImplementation("com.github.mohdaquib.ComposeA11yScanner:scanner-ui:2.1.0")
}That is all the integration required. AndroidX Startup attaches the overlay and runs an initial
scan for every ComponentActivity in a debuggable app.
Important
Keep the dependency and all direct scanner calls in debug source sets. Do not add the scanner
with implementation or reference it from release sources.
Call the API from a debug source set, such as src/debug/java/.../ScannerActions.kt:
import com.composea11yscanner.ComposeA11yScanner
ComposeA11yScanner.triggerScan()ComposeA11yScanner.scan() is safe to collect before the first activity reaches onResume when
automatic installation is enabled. The flow waits for an installed activity scanner, then forwards
its state.
Add scanOnShake() to a debug-only composable that remains active while the screen is visible:
import com.composea11yscanner.triggers.scanOnShake
@Composable
fun App() {
scanOnShake()
AppContent()
}All built-in rules are enabled by default, and the overlay is removed when its activity is destroyed.
Keep direct scanner imports in src/debug; dependencies added with debugImplementation are
intentionally unavailable to release source sets.
Optional manifest metadata controls the auto-installed scanner:
<application>
<meta-data
android:name="a11y_scanner_min_contrast"
android:value="4.5" />
<meta-data
android:name="a11y_scanner_auto_scan"
android:value="false" />
</application>Use manual installation only when you need a programmatic ScannerConfig. First disable the automatic initializer in the app manifest:
<manifest xmlns:tools="http://schemas.android.com/tools">
<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="merge">
<meta-data
android:name="com.composea11yscanner.A11yScannerInitializer"
tools:node="remove" />
</provider>
</application>
</manifest>Then install after setContent:
setContent { App() }
ComposeA11yScanner.install(
activity = this,
config = ScannerConfig(
enabledRules = ScannerRules.allRuleIds().toSet(),
minContrastRatio = 4.5f,
autoScan = false,
),
)Do not combine automatic and manual installation. Repeated installation on the same activity is ignored, but keeping one ownership path makes configuration predictable.
For Navigation Compose, provide the current route when installing manually. An explicit key reliably invalidates stale results even when two destinations have the same semantics structure:
ComposeA11yScanner.install(
activity = this,
destinationKeyProvider = {
navController.currentBackStackEntry?.destination?.route
},
)If a navigation framework cannot expose a route provider, automatic host/semantics detection remains enabled. A custom navigator can also invalidate the current result explicitly:
ComposeA11yScanner.notifyScreenChanged()A11yScannerScaffold is the advanced API for apps that want the scanner UI inside their own Compose hierarchy or need a custom node provider. It requires an A11yScannerController; most integrations should use the automatic activity overlay above.
A11yScannerScaffold(
scannerController = scannerController,
config = config,
modifier = Modifier.fillMaxSize(),
) {
AppContent()
}See RULES.md for complete behavior, fixes, WCAG references, and examples.
| Rule | Severity | Detects |
|---|---|---|
| Touch Target Overlap | Warning | Interactive elements whose effective touch and visual bounds overlap. |
| Missing Content Description | Error | Interactive or image-like elements without a readable label. |
| Duplicate Content Description | Warning | Distinct controls in the same logical scope that expose the same description. |
| Focus Order | Error | Focus traversal that conflicts with the expected visual reading order. |
| Text Scaling | Warning | Text likely to clip or overflow when the user increases font size. |
| Image With Text Overlay | Warning | Text overlapping an image, where dynamic content can create contrast risk. |
| Clickable Role | Error | Clickable elements without an appropriate semantic role or label. |
| Text Contrast | Warning | Confidently measured rendered text below the configured contrast ratio. |
TextContrastRule complements semantics-based checks with rendered-pixel analysis. The scanner
captures the selected Compose host once, samples enabled semantic Text nodes, and applies the WCAG
relative-luminance formula when it can confidently identify a foreground and a solid-looking
background. The default minimum ratio is 4.5:1 and can be changed through ScannerConfig or manifest
metadata.
The estimator intentionally skips uncertain results instead of guessing. Keep these boundaries in mind when interpreting a scan:
- Photos, gradients, textured surfaces, and other visually ambiguous text backgrounds may be skipped.
- Text drawn on a
Canvas, embedded in an image, or otherwise absent from Compose semantics is not discovered through OCR. - One configurable ratio is applied to measured text; separate large-text thresholds are not inferred.
- A scan represents the currently rendered theme, state, content, and destination. Scan every state that users can encounter.
- Automated findings complement, but do not replace, testing with TalkBack, font scaling, keyboard or switch access, and human accessibility review.
If a result appears incorrect, include the affected screen, scanner version, exported scan result, and a minimal reproduction when opening an issue.
Create a rule by implementing A11yRule. Use a stable ruleId, assign a severity, and return an A11yIssue only when the node fails your check.
class MissingTestTagRule : A11yRule {
override val ruleId = "missing-test-tag"
override val ruleName = "Missing Test Tag"
override val severity = A11ySeverity.Warning
override val wcagReference: String? = null
override fun evaluate(node: A11yNode): A11yIssue? {
if (!node.isTouchTarget || node.isMergedDescendant) return null
if (node.composableName.contains("TestTag", ignoreCase = true)) return null
return A11yIssue(
issueId = "${ruleId}_${node.nodeId}",
severity = severity,
ruleId = ruleId,
ruleName = ruleName,
affectedNode = node,
message = "Interactive node does not expose a stable test tag.",
howToFix = "Add Modifier.testTag() to make this control easier to identify in tests.",
wcagReference = wcagReference,
)
}
}Register custom rules on the controller:
val scannerController = A11yScannerController(
nodeProvider = { extractNodesFromCurrentSemanticsTree() },
screenDensity = density,
).withRules(MissingTestTagRule())Custom rule IDs are automatically enabled by A11yScannerController.withRules(...) before each scan.
flowchart LR
SemanticsTree["SemanticsTree"] --> Extractor[":scanner-ui<br/>A11yNodeExtractor"]
Extractor --> Nodes["A11yNode list"]
Nodes --> Core[":scanner-core<br/>A11yScanEngine"]
Rules[":scanner-rules<br/>Built-in and custom rules"] --> Core
Core --> Result["ScanResult / ScannerState"]
Result --> Overlay[":scanner-ui<br/>Overlay and issue details"]
:scanner-core owns the scan engine and public models. :scanner-rules contains built-in rules. :scanner-ui handles Android/Compose integration, node extraction, triggers, and the overlay.
Questions, bug reports, rule proposals, and pull requests are welcome. Use GitHub Issues and choose a title that identifies whether the report is a false positive, false negative, integration problem, or feature request.
For scanner-result problems, please include:
- ComposeA11yScanner version and Android version.
- Navigation and hosting setup, such as Navigation Compose, Fragments, or nested
ComposeViews. - A screenshot and exported scan-result JSON with sensitive information removed.
- Expected behavior, actual behavior, and reproduction steps.
See the sample app for broken and corrected examples of the bundled rules, and browse the API documentation for public types and functions.
ComposeA11yScanner has been featured in the Jetpack Compose Newsletter and Android Weekly.
ComposeA11yScanner is available under the Apache License 2.0.
