ColorScheme in Material 3: How to Use Semantic Colors

Quick answer:
ColorSchemeis Material 3’s set of named color roles. Read it withMaterialTheme.colorScheme, choose a role for the UI job—not a favorite hue—and pair every background or container with its matchingon*content color. For example, useprimarywithonPrimary,secondaryContainerwithonSecondaryContainer, anderrorContainerwithonErrorContainer.
When a Compose screen has a few hard-coded Color(...) values, it can look correct in one screenshot but fail in dark theme, dynamic color, or a future brand refresh. Semantic roles solve that problem: the component says “this is a supporting container,” while the active theme decides the actual colors.
The Material 3 ColorScheme API describes it as the named color parameters of a MaterialTheme. The scheme is designed so its roles work together for contrast and visual hierarchy.
Start with the role, not the hex value
Ask what the element is doing before choosing a color:
| UI job | Common ColorScheme role | Content role |
|---|---|---|
| Main action or selected state | primary | onPrimary |
| Main-action container with a softer tonal treatment | primaryContainer | onPrimaryContainer |
| Supporting emphasis | secondary or secondaryContainer | Matching onSecondary* role |
| Distinct accent or heightened attention | tertiary or tertiaryContainer | Matching onTertiary* role |
| Standard screen or component region | surface | onSurface |
| Secondary text or icon on a surface | onSurfaceVariant | Applied over a surface/container, not as a background |
| Error state | error or errorContainer | Matching onError* role |
| Border or separator | outline or lower-emphasis outlineVariant | Not normally used as a content background |
The names describe intent, not a fixed color. primary might be blue in a static light theme, pale lavender in dark theme, and wallpaper-derived when dynamic color is enabled. The component should keep working in all three cases.
ColorScheme also includes surface variants such as surfaceContainer, surfaceContainerHigh, and surfaceContainerLow. They express a hierarchy of container emphasis for areas such as cards, sheets, and menus. The Android API reference documents these roles as part of the Material 3 scheme; use them when your Compose Material 3 version provides them, rather than copying a surface color from a design screenshot.
Pair every background with its matching on* role
The most useful rule is simple: if code chooses a role for a background, choose the matching on* role for text and icons drawn on it.
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SyncStatus() {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
shape = MaterialTheme.shapes.medium,
) {
Text(
text = "Synced just now",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyLarge,
)
}
}Passing contentColor to Surface lets nested Text and Icon composables inherit the appropriate local content color. You can also set a color explicitly when it makes the component easier to understand:
Text(
text = "Synced just now",
color = MaterialTheme.colorScheme.onSecondaryContainer,
)What does not work reliably is mixing pairs because they happen to look good in today’s palette:
// Avoid: onPrimary is designed for content over primary, not over a container.
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Text("Synced just now", color = MaterialTheme.colorScheme.onPrimary)
}The mismatch can become unreadable when dark or dynamic color changes the palette. The dynamic color and dark theme guide explains why this matters beyond a single light-mode preview.
Accent roles: primary, secondary, and tertiary
These three families provide different levels of emphasis. Their exact hue is theme data; their job is what feature code should depend on.
primary for the most important action or selection
Use primary when an element needs strong, consistent emphasis: a filled primary action, a selected navigation item, or a progress indicator. In most cases, let Material components use it by default:
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun SaveChangesButton(onSave: () -> Unit) {
Button(onClick = onSave) {
Text("Save changes")
}
}The default Button already reads from the active ColorScheme. Override its colors only when the interaction truly calls for a different semantic level. The Material 3 buttons guide compares those component variants.
Containers for prominent but less visually heavy regions
primaryContainer, secondaryContainer, and tertiaryContainer are tonal backgrounds. They are useful for selected settings, summary cards, filter chips, status callouts, and other bounded regions that need emphasis without making every item a filled primary action.
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun StorageSummary(used: String) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shape = MaterialTheme.shapes.large,
) {
Column(Modifier.padding(20.dp)) {
Text("Storage used", style = MaterialTheme.typography.labelLarge)
Text(used, style = MaterialTheme.typography.headlineMedium)
}
}
}Use secondaryContainer for support-level information and tertiaryContainer when the product needs a clearly distinct accent. Do not assign an arbitrary meaning to a family globally—such as “tertiary always means success”—unless that is a deliberate, documented part of your design system.
Surface roles build the background hierarchy
Surfaces are the neutral canvas behind much of an app’s content. surface is a good default for a screen region; onSurface is the default high-emphasis content over it. onSurfaceVariant is intended for lower-emphasis content such as supporting text, a secondary icon, or a quiet metadata label.
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ProfileHeader(name: String, handle: String) {
Column(Modifier.padding(20.dp)) {
Text(
text = name,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge,
)
Text(
text = handle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
}Prefer a surface-container role for a group that needs to separate from the screen, such as a settings section or a custom card. The default Material Card, ModalBottomSheet, and menu colors already make theme-aware choices, so start with their defaults. Use direct roles when creating custom containers or when a design requires a purposeful visual variant. For the component-level trade-offs, see Cards, Surface, and ListItem.
Do not use background as a universal replacement for surface. It is a distinct theme role. Likewise, avoid treating surfaceVariant as a synonym for every custom card when a surface-container role is available in your Material 3 dependency.
Error colors communicate an error, not every warning
Use error for high-emphasis error content and errorContainer for a bounded error message. Pair each with its matching foreground role.
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ValidationMessage(message: String) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
shape = MaterialTheme.shapes.small,
) {
Text(
text = message,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}The color should reinforce clear text such as “Email address is invalid”; it should not be the only way a user learns that a field has a problem. Connect the message to the field and expose the error semantics needed by assistive technology. The Compose form validation guide covers the state and interaction side of this pattern.
Material 3 has no universal built-in success or warning role in ColorScheme. If the product needs those statuses, define named tokens at the theme boundary, provide accessible foreground/background pairs for both light and dark modes, and consume those tokens consistently. Do not scatter Color.Green through feature code and call it a semantic system.
Define a scheme at the theme boundary
Set brand values in light and dark schemes near AppTheme; feature UI should consume roles from MaterialTheme.colorScheme.
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
private val LightColors = lightColorScheme(
primary = Color(0xFF005AC1),
onPrimary = Color.White,
primaryContainer = Color(0xFFD9E2FF),
onPrimaryContainer = Color(0xFF001A41),
secondaryContainer = Color(0xFFD8E2FF),
onSecondaryContainer = Color(0xFF101C2B),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
)
private val DarkColors = darkColorScheme(
primary = Color(0xFFADC6FF),
onPrimary = Color(0xFF002E69),
primaryContainer = Color(0xFF004494),
onPrimaryContainer = Color(0xFFD9E2FF),
secondaryContainer = Color(0xFF3C4858),
onSecondaryContainer = Color(0xFFD8E2FF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)This example is an illustrative static palette, not a requirement to define every constructor parameter. lightColorScheme() and darkColorScheme() provide baseline values for roles you do not override. Review the whole scheme in light and dark mode before shipping; hand-picked overrides can undermine contrast or hierarchy if they are treated as isolated swatches.
Your AppTheme selects one scheme and gives it to MaterialTheme. The MaterialTheme guide shows that full setup, including typography and shapes. On Android 12 and newer, dynamicLightColorScheme() and dynamicDarkColorScheme() can supply the scheme when personalization suits the product; retain static fallbacks for older devices.
Let Material components use their defaults first
Material 3 components consume MaterialTheme.colorScheme by default. Keeping the defaults has three benefits:
- a component stays aligned with the theme’s light, dark, and dynamic palettes;
- disabled, pressed, focused, and selected states retain Material’s intended handling;
- feature code does not need to recreate a component’s color matrix.
Use a component defaults factory when a real design decision requires an override, and still keep the pair semantic:
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun ArchiveButton(onArchive: () -> Unit) {
FilledTonalButton(
onClick = onArchive,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text("Archive")
}
}Avoid assuming contentColorFor() can rescue any arbitrary color. It can map known scheme background roles to their matching content colors, but it returns Color.Unspecified for a color that is not present in the scheme. Explicit pairs communicate the intent more clearly for custom UI.
Test the theme combinations that users see
Color bugs are often state bugs in disguise. A component that looks fine in one static light preview can become unreadable when it is disabled, selected, shown over a container, or rendered from a dynamic dark scheme.
- Preview custom components inside the real
AppThemein static light and dark modes. - Test high- and low-emphasis text on every custom container you introduce.
- If dynamic color is enabled, inspect more than one wallpaper-derived palette on Android 12+.
- Check errors, disabled controls, selected states, dialogs, sheets, and navigation elements—not only the idle screen.
- Use a contrast checker and accessibility testing for the final product; semantic naming supports contrast, but it does not replace visual review of custom palette overrides.
A practical decision rule
Use a ColorScheme role when it describes the UI element’s job. Use a matching on* role for content over it. Let Material components pick defaults before overriding them. If a needed product meaning does not exist in Material 3—such as success—create a named, accessible theme token rather than a repeated literal color.
That approach makes custom Compose UI follow the same visual system as Material components, no matter which palette the app is using.