ColorScheme in Material 3: How to Use Semantic Colors

Quick answer: ColorScheme is Material 3’s set of named color roles. Read it with MaterialTheme.colorScheme, choose a role for the UI job—not a favorite hue—and pair every background or container with its matching on* content color. For example, use primary with onPrimary, secondaryContainer with onSecondaryContainer, and errorContainer with onErrorContainer.

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 jobCommon ColorScheme roleContent role
Main action or selected stateprimaryonPrimary
Main-action container with a softer tonal treatmentprimaryContaineronPrimaryContainer
Supporting emphasissecondary or secondaryContainerMatching onSecondary* role
Distinct accent or heightened attentiontertiary or tertiaryContainerMatching onTertiary* role
Standard screen or component regionsurfaceonSurface
Secondary text or icon on a surfaceonSurfaceVariantApplied over a surface/container, not as a background
Error stateerror or errorContainerMatching onError* role
Border or separatoroutline or lower-emphasis outlineVariantNot 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 AppTheme in 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.