Foundations
The cross-cutting accessibility rules that apply across most Jetpack Compose work, independent of any single component. Each rule is a best practice an AI applies while authoring Compose code. Component patterns reference these rules rather than restating them.
Verification (audits, contrast measurement, on-device and human review) is a QA concern and lives in the QA layer, not here.
Rule: Native First
Scopecomponent
Must Haves
- An interactive control exposes three things to accessibility services: its role, its current state, and its actions. Reach for the component that already does all three rather than assembling one that does none.
- The Material 3 composable is the reference implementation of that contract. A component that wraps the Material composable satisfies it too, as does one that sets the same role, state, and actions itself.
- A control assembled from
Row,Box,Image, andModifier.clickablesatisfies none of it by default, and has to declare all three by hand. When that is the only option, followglobal.custom-control-semantics. - When a component's own pattern states a fallback and its conditions, follow that pattern rather than this rule. The conditions under which the reference implementation cannot be used differ by component.
Donts
- Do not treat a visual match as a semantic match. A
Rowcontaining a check glyph and a label renders like a checkbox and exposes none of a checkbox's role or state. - Do not assume a component satisfies the contract because it is named after the control. Read what it sets: a wrapper that forwards to
Modifier.toggleable(role = Role.Checkbox)qualifies, and one that forwards to a bareclickabledoes not. - Do not hand-assemble a control from
androidx.compose.foundationprimitives when a component that already meets the contract is available to the project, whether that is the Material composable or the design system's own. Assembling one is the fallback, not the starting point.
Rule: Merged Semantics
Scopecomponent
Must Haves
- Treat a composable that carries
Modifier.clickable,Modifier.toggleable,Modifier.selectable, or a MaterialListItemas a single accessibility node. Each of these setsmergeDescendants = trueinternally, so every descendant's text collapses into one announcement. - When several text elements describe one thing, let the merge happen and check that the resulting announcement reads in a sensible order, because the merged name is the concatenation of descendant text in traversal order.
- When a row needs one primary action plus a secondary one, expose the secondary action as a
CustomAccessibilityActionon the merged node rather than as a nested interactive child. TalkBack surfaces custom actions through its actions menu, which keeps the row a single stop. - When a decorative child would pollute the merged name, remove it from the tree with
Modifier.clearAndSetSemantics { }or, for anImageorIcon, withcontentDescription = null. - Apply
Modifier.semantics(mergeDescendants = true) { }explicitly when a group of text and images forms one unit but carries no interaction, so it reads as one item instead of several.
Donts
- Do not nest an interactive child inside an interactive parent. A child that merges cannot be absorbed by a parent that merges, so the result is two competing targets rather than the single row the layout suggests.
- Do not use
Modifier.clearAndSetSemantics { }to hide a focusable control. It removes the node from every consumer, including the accessibility tree, leaving a control that is visible and operable by touch but unreachable by assistive technology. - Do not rely on modifier order being irrelevant.
Modifier.clearAndSetSemanticsclears every semantics property applied after it in the chain.
Rule: Touch Target Size
Scopecomponent
Must Haves
- Every tappable control accepts a tap across at least 48x48dp, matching Material's accessibility guidance. Treat 24x24dp as the absolute floor and 48dp as the size to build to.
- Inline targets within a run of text are exempt from the minimum.
Modifier.clickableandcombinedClickablealready extend the tap area of an undersized control out to this minimum, so a Material component reaches it even when its drawn bounds are smaller. Material's button family relies on exactly this rather than reserving the space.
- Reserve the space with
Modifier.minimumInteractiveComponentSize()where the control needs to occupy 48dp rather than merely accept a tap across it, such as when neighboring targets would otherwise sit closer than their own minimums allow. Place it before any size-constraining modifier, because a latersize()orheight()overrides it. - When state is lifted out of a Material selection control so its own callback is null, size the wrapping
Modifier.toggleableorModifier.selectablerow to at least 48dp.Checkbox,RadioButton,Switch, andSliderapply the minimum only while they own the callback, so hoisting state moves the obligation to the parent. - When building a control from primitives, size it explicitly with
Modifier.sizeIn(minWidth = 48.dp, minHeight = 48.dp)orModifier.defaultMinSize(...). - Place
Modifier.paddingafterModifier.clickablein the chain when the padding should be part of the target. Order decides this: padding applied afterclickableis inside the tappable area, and padding applied before it is not.
Donts
- Do not assume the automatic extension covers a control that detects taps some other way. It is applied by
Modifier.clickableandcombinedClickable; a control built onpointerInputor a custom gesture detector gets none of it and has to be sized explicitly. - Do not set
LocalMinimumInteractiveComponentSizeto0.dpto make a dense layout fit. That disables the enforcement for everything beneath it. - Do not pad a control from the outside and count the padding as target.
Modifier.padding(12.dp).clickable { }leaves the padding inert; the tappable area is still only the content.
Rule: State Description
Scopecomponent
Must Haves
- Express a control's current state through
Modifier.semantics { stateDescription = "..." }when the state is discrete, such as selected, expanded, or a named mode. - Express a control's current value through
progressBarRangeInfowhen the value is continuous or ranged, such as a slider position or a determinate progress amount. The two properties are separate, and the choice between them is the choice between a state and a value. - Override the default state wording only when the visible wording differs from it. A switch showing "Allowed" and "Blocked" needs a
stateDescriptionmatching those words, because the default announcement says on and off. - Keep the state out of the name.
contentDescriptionsays what the control is;stateDescriptionsays what it currently is set to.
Donts
- Do not concatenate the state into
contentDescription. The name changes as the state changes, so the control appears to be a different control after every interaction. - Do not set
stateDescriptionon a control whose Material composable already reports its state correctly, which duplicates the announcement.
Rule: Icons and Images
Scopecomponent
Must Haves
- Give an
ImageorIconthat carries meaning acontentDescriptionnaming what it conveys, not what it depicts. - Give an
ImageorIconthat is decorative, or that sits beside text saying the same thing,contentDescription = nullso it leaves the accessibility tree. - When an icon is the only content of a control, the control carries the name and the icon inside it is decorative. Name the
IconButton, not both.
Donts
- Do not describe the artwork when the icon stands for an action. A trash glyph on a delete control is named "Delete", not "Trash can".
- Do not leave
contentDescriptionunset on a meaningful graphic. An unset description is not the same asnull, and the element reports no name at all.
Rule: Semantic Color
Scopecomponent
Must Haves
- Colors for text, surfaces, and control chrome come from a theme whose values are checked against each other as a set and which adapts to light and dark, not from literals written at the call site.
MaterialTheme.colorSchemeis the reference implementation of that: its roles are paired tonal values, contrast-checked against theironcounterparts, and switched automatically byisSystemInDarkTheme(). A design token set that is a theme in the same sense qualifies the same way.- Use Material components with their default styling so a control's container, border, and state layers inherit their color relationships rather than being drawn by hand.
- Give any custom-drawn indicator a color-scheme role rather than a literal.
- When the app responds to the system contrast setting, read it through
UiModeManager.getContrast()and register aContrastChangeListener, then select a higher-contrast scheme. Android 14 and later.
Donts
- Do not hardcode colors as
Color(0xFF...)orColor.Blackfor text or control chrome. A literal at the call site belongs to no theme, so it neither adapts nor participates in any contrast relationship. - Do not assume the color scheme responds to the system "Increase contrast" setting. It adapts to light and dark on its own; contrast level is a separate integration the app opts into.
- Do not restyle a Material component in a way that removes its container or state-layer contrast.
Rule: Use of Color
Scopecomponent
Must Haves
- Carry every piece of information on a second channel besides color: a shape, an icon, a text label, or a position.
- Pair a color-coded status with its name in text or in
stateDescription, so the status is announced as well as shown. - Distinguish a selected item by more than its fill. Material's selection controls do this already through their glyph; a custom selectable surface does not.
Donts
- Do not signal an error state with a red border alone. Pair it with
isError, an error message, andModifier.semantics { error("...") }. - Do not distinguish a link from its surrounding text by color alone.
Rule: Text Scaling
Scopelayoutcomponent
Must Haves
- Size text in
spso it scales with the user's font-size setting. Use theMaterialTheme.typographystyles rather than a literal size wherever one fits. - Let containers grow with their content, and place any screen that can overflow inside a
verticalScrollor aLazyColumn. - Size a non-text dimension that must track the type scale by reading
LocalDensity.current.fontScaleand multiplying, since Compose has no modifier that rescales adpvalue by the font scale.
Donts
- Do not size text in
dp. It ignores the user's font-size setting entirely. - Do not read
Configuration.fontScaleor a density-derived scalar as a single multiplier for text size. Android 14 and later scale large text proportionally less than small text, so no one factor describes the result. - Do not apply a fixed
heightto a container holding scalable text, and do not add a truncatingmaxLinesto meaningful content.
Rule: Focus States
Scopecomponent
Must Haves
- Give any composable made focusable with
Modifier.focusable()an explicit focus treatment, because a bare focusable composable draws nothing at rest. - Draw the indicator as a border of at least 2dp around the control, in a
MaterialTheme.colorSchemerole that differs from both the control's own container color and the surface behind it. Key it toisFocusedfromModifier.onFocusChanged { }for a single control, or to a customIndicationandIndicationNodeFactorywhere the treatment is reused. - Show the indicator only while
LocalInputModeManager.current.inputModeisInputMode.Keyboard, so it appears for hardware keyboard and D-pad navigation and not after a touch tap. This is Compose's equivalent of the web's:focus-visible, and unlike the web it is not automatic. - Keep the focus treatment distinguishable from the hovered and pressed states. Material's default state layers separate focus from hover by a small opacity step, which does not reliably read as focus on its own.
Donts
- Do not treat the default ripple or state layer as the focus indicator on a control you have styled yourself. It communicates a press, not a resting focus position.
- Do not remove a Material component's
indicationwithout supplying a replacement. - Do not distinguish the focus indicator from the unfocused state by hue alone, since that fails for users who cannot separate the two colors.
Rule: Focus Not Obscured
Scopelayoutcomponent
Must Haves
- Scroll a newly focused item into view inside a scrolling container, using
BringIntoViewRequesterorLazyListState.animateScrollToItem, so focus never lands behind fixed chrome. - Account for a
TopAppBar, aBottomAppBar, aNavigationBar, or a bottom sheet overlapping the scrolling content beneath them.
Donts
- Do not rely on the scroll container's default behavior to reveal a focused item. It scrolls the item into the viewport, which is not the same as clearing fixed chrome drawn on top of that viewport.
Rule: Motion
Scopecomponent
Must Haves
- Build animation from Compose's own APIs, such as
animate*AsState,Animatable, andAnimatedVisibility. They read the system animation scale throughMotionDurationScaleand shorten or skip motion when the user turns animations off, with no extra code. - When a hand-rolled timing loop is unavoidable, read
Settings.Global.ANIMATOR_DURATION_SCALEand skip the motion when it is 0. There is no ComposeCompositionLocalthat reports the reduced-motion preference, and noisReduceMotionEnabled()API exists. - Give any motion that starts on its own and runs longer than five seconds a control to pause or stop it, or replace it with a static equivalent. This covers auto-advancing carousels, looping background video, and animated placeholders.
- Move focus off a composable before removing it from composition, so focus is not lost when an animated element leaves the tree.
Donts
- Do not drive motion from a
LaunchedEffectwith fixed delays. It bypasses the system animation scale entirely. - Do not attach meaning to motion alone, such as signaling an error only by a shake.
Rule: Custom Control Semantics
Scopecomponent
Must Haves
- Declare a role on any control drawn from
Canvas,Layout, or raw gestures, usingModifier.semantics { role = Role.X }where the closedRoleset has a member that fits. - Declare the full bundle the native equivalent would have provided, because Compose has no API that exposes a hidden native control behind a custom-drawn one. It supplies the pieces and the author assembles them:
- A toggle-like control needs a role, a
stateDescription, andModifier.toggleableor anonClickaction. - An adjustable control needs a role,
progressBarRangeInfo, and asetProgressaction. - A selectable control needs a role,
selected, andModifier.selectableinside aselectableGroup.
- A toggle-like control needs a role, a
- Bind the declared state to the same source the visible control draws from, so the announced value cannot drift from the rendered one.
- When no
Rolemember fits, carry the meaning incontentDescriptionandstateDescriptionand declare the actions, rather than claiming a role that misdescribes the control.