Color Contrast and Dark Theme Accessibility in Material 3

Quick answer: Let Material 3 components use the active
ColorSchemewhenever possible. For custom UI, choose a semantic background role and its matchingon*content role—such assurfacewithonSurfaceorprimaryContainerwithonPrimaryContainer. Check both light and dark schemes, include selected/error/disabled states, and never make color the only signal of status or action. Treat 4.5:1 for normal text and 3:1 for large text and graphics as practical minimum contrast targets, then verify the real screen.
Dark theme is not “invert every color.” It is a distinct system of surfaces, accents, and foreground colors that must preserve hierarchy without glare or faint text. Material 3 gives Compose a strong starting point, but custom containers, alpha overlays, brand colors, and status indicators can still break contrast if they bypass the theme’s semantic pairs.
Use contrast targets as a check, not a palette recipe
Android’s app-quality guidance sets a minimum of 4.5:1 for small text and 3:1 for large text and graphics. The underlying WCAG contrast criterion makes the same distinction: normal text needs the higher ratio; large text may use 3:1.
| UI content | Practical minimum | Review beyond the number |
|---|---|---|
| Body text, labels, helper text, button text | 4.5:1 | Check small sizes, thin weights, and disabled/read-only meaning. |
| Large headings and meaningful graphics | 3:1 | Confirm it remains distinct on the real surface. |
| Focus rings, component boundaries, selected indicators | 3:1 where the indicator conveys state | Verify it does not disappear against adjacent surfaces. |
| Decorative visuals | No contrast target when they communicate nothing | Keep them from reducing contrast behind meaningful content. |
These are thresholds, not an excuse to aim for the faintest passing color. A thin font, translucent foreground, image background, or visually busy surface can make nominally passing text hard to read. Conversely, contrast does not fix unclear wording, a tiny touch target, or an action represented only by a hue.
Let Material color pairs do the first job
Material 3 colors are semantic roles, not a bag of interchangeable swatches. If a component’s background comes from one role, content placed on it should use its matching on* role.
| Background or container | Foreground content |
|---|---|
primary | onPrimary |
primaryContainer | onPrimaryContainer |
secondaryContainer | onSecondaryContainer |
error | onError |
errorContainer | onErrorContainer |
surface | onSurface |
The Material 3 Compose documentation explains that the tonal palettes and role pairs are selected to meet accessibility requirements. That protection holds only while the pair stays intact. onPrimary is not a universal light text color, and primaryContainer is not a universal foreground color.
@Composable
fun OfflineNotice(onRetry: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
shape = MaterialTheme.shapes.medium,
) {
Column(Modifier.padding(16.dp)) {
Text(
text = "You are offline",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Reconnect to load the latest articles.",
style = MaterialTheme.typography.bodyMedium,
)
TextButton(
onClick = onRetry,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text("Try again")
}
}
}
}Surface supplies the declared contentColor to children such as Text and Icon unless they deliberately override it. Components with their own default colors, such as a TextButton, should receive an explicit, contrast-checked color when the surrounding container is customized. This is safer than setting a background with Modifier.background() and relying on a default text color that was designed for a different surface. ColorScheme in Material 3: How to Use Semantic Colors covers the role system in more detail.
Build dark surfaces as a hierarchy
In dark theme, a single near-black background behind every app region makes cards, sheets, menus, and selection states hard to distinguish. Material 3 uses surface and surface-container roles plus tonal elevation to create restrained separation without turning every region into a saturated accent.
Start by defining and providing complete light and dark schemes at the app boundary:
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val LightColors = lightColorScheme(
// Define the product's semantic roles here.
)
private val DarkColors = darkColorScheme(
// Define dark values for the same semantic roles here.
)
@Composable
fun AppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
content = content,
)
}Feature composables should then consume roles, not branch on isSystemInDarkTheme() themselves. This keeps a dark theme from slowly becoming a collection of one-off if (darkTheme) colors. The isSystemInDarkTheme() reference recommends using the setting near the top of the hierarchy for exactly that reason.
For standard cards, buttons, app bars, sheets, and menus, begin with Material defaults. When a custom surface needs hierarchy, use a surface-container role available in the project’s Material 3 version, or a Surface with purposeful tonal elevation. Do not assume that a black Color plus low-alpha white text is a dark scheme; alpha compositing depends on the background beneath it and can quietly lower contrast.
Override colors only as complete pairs
Customizing a component is safe when the container and content are chosen together. The first example below preserves the paired roles; the second mixes roles that were not designed to sit together.
// Good: a matched pair from one semantic family.
Button(
onClick = onSave,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Text("Save changes")
}
// Avoid: these roles can have poor contrast in a different scheme.
Button(
onClick = onSave,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Text("Save changes")
}The Material 3 guide uses this kind of mismatch as a contrast failure: a combination that happens to look acceptable in one screenshot can become unreadable in a dark or dynamic scheme. Prefer defaults where possible; they preserve state colors such as disabled, pressed, and focused variants too.
If your product needs custom success, warning, or information colors, define a small named token set at the theme boundary. Give each token a foreground/background pair for light and dark schemes, document the meaning, and test it. A scattered Color.Green is not an accessible success system.
Do not make status depend on hue alone
People may use a monochrome display, have reduced color discrimination, or simply miss a subtle hue difference in bright light. Color can reinforce a state, but it should not be the only evidence that a field is invalid, a filter is selected, or a task succeeded.
@Composable
fun TopicFilter(
selected: Boolean,
onSelectedChange: (Boolean) -> Unit,
) {
FilterChip(
selected = selected,
onClick = { onSelectedChange(!selected) },
label = { Text("Compose") },
leadingIcon = if (selected) {
{
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
)
}
} else {
null
},
)
}The selected chip uses the Material selected treatment and a check mark. Error UI should similarly include a visible error message, field label, and semantic error information—not a red border alone. Compose Semantics: Roles, Labels, States, and Actions explains how the semantic state reaches assistive technology.
For custom focusable components, make focus clear with a shape, outline, elevation, or another non-color distinction that remains visible in both themes. Test it beside selected and error states so the three do not look interchangeable.
Treat dynamic color as an additional scheme to test
Material dynamic color is designed around accessible tonal palettes, but custom UI can still undermine it by hard-coding a foreground, layering transparency, or mixing roles. Static light and dark previews make regressions repeatable; a device or emulator on Android 12+ checks how wallpaper-derived color changes the real UI.
Use a matrix that includes more than the home screen:
| Scheme or condition | Review |
|---|---|
| Static light | Text on cards, primary action, selected and error states. |
| Static dark | Surface separation, secondary text, dialogs, sheets, focus indication. |
| Dynamic light and dark | Custom containers, branded accents, and role-pair overrides. |
| Large font | Secondary text, button labels, error copy, and contrast after reflow. |
| Disabled and loading | State is understandable without making essential text vanish. |
Dynamic Color and Dark Theme in Jetpack Compose covers the API-31 dynamic-color policy and static fallbacks. Pair it with Support Large Font Sizes and Font Scaling in Compose because large text often turns a quiet secondary label into a more prominent, multi-line element that needs a fresh contrast check.
Measure, inspect, and listen
Use a contrast checker on the actual foreground and background values, including any alpha blending. Inspect dark and light surfaces in Compose Preview and on a device. Accessibility Scanner can detect some mechanical problems, while Layout Inspector can reveal the semantics Compose exposes. Neither replaces a manual test with TalkBack and a visual review in real lighting conditions.
Ask these questions for each important state:
- Can a person identify the action or status without relying on its hue?
- Does small supporting text remain readable on the exact container behind it?
- Does the focus cue remain visible in light, dark, selected, error, and disabled contexts?
- Does a custom component still use a matching
on*foreground after dynamic color or dark theme changes? - Are labels and semantic descriptions still available when a visual color cue is missed?
Common mistakes
Using a favorite brand color everywhere
A vibrant light-theme accent may be too dark for a dark surface or too light for white content. Put brand colors into the relevant light and dark semantic roles; do not assign one hex value directly to every button, icon, and label.
Applying low-alpha content over unknown backgrounds
An alpha value is not a contrast ratio. The final color changes with every surface below it, especially in layered dark UI. Prefer opaque role pairs for essential content.
Mixing on* and container roles
onPrimaryContainer belongs over primaryContainer, not over a random card. Pair roles by family or let Material choose them.
Treating red, green, or selected color as the complete state
Add text, a check, an icon, an outline, position, or semantic state. The color should reinforce meaning, never carry it alone.
Checking only the default dark screen
Dialogs, bottom sheets, disabled buttons, error copy, and dynamic palettes are where low contrast usually appears. Review the full state matrix.
Contrast and dark-theme checklist
- Use Material components and
ColorSchemeroles before custom color overrides. - Pair every chosen container/background with its matching
on*foreground. - Meet practical 4.5:1 normal-text and 3:1 large-text/graphic minimums, then review the actual rendering.
- Build dark mode from semantic surfaces and restrained hierarchy, not black backgrounds plus transparent text.
- Give selected, error, success, and focus states a cue beyond color.
- Review static light/dark, dynamic light/dark, large font, disabled, loading, and error states.
- Test with visual inspection, contrast tools, and assistive technology before release.
Accessible contrast is a property of a complete state: foreground, background, size, weight, transparency, and meaning all together. Material 3’s color system handles much of that work—provided custom UI continues to speak its semantic language.