Pocket Studio Academy
HomePart 33.11

Material 3 theming and colour

Full course12 min read·4 questions

Stop writing Color(0xFF5B3FD6) in forty places. Material 3 asks you for a colour per role instead of per widget, which is what makes one edit repaint a whole app — and what makes a dark mode that is genuinely designed rather than inverted.

The screen that vanishes at sunset

Everything you have built in Part 3 has had its colours written into it by hand:

kotlin
1Text(
2    text = "DICE DUEL",
3    color = Color(0xFF5B3FD6)
4)

That is honest and it works. It also has two problems, and the second one is worse than it sounds.

Problem one: that violet appears in the title, the buttons, the win banner and the score bars. Twelve files. Decide it should be a shade cooler and you are doing find-and-replace on a hex code, hoping you catch all of them.

Problem two: the phone switches to dark mode at sunset. Your background is drawn from a Color(0xFFF7F4FF) that never heard about dark mode, so it stays near-white — or, if you used a Material default somewhere, the background goes dark and your hand-picked near-black text becomes invisible on it. Half your app is now unreadable and none of your code changed.

A fixes both, and it does it by changing the question you are asked.

Think of it like this

Think about a decorator's job sheet for a house.

A bad job sheet says: skirting boards — tin 4471. Front door — tin 4471. Hall walls — tin 2210. It works, right up until the owner wants a different front door, and now somebody has to read every line to work out which 4471s were the door colour and which were the skirting.

A good job sheet never names a tin. It says: trim colour on the skirting, trim colour on the door, wall colour on the hall. Then one small card, pinned by the door, says which tin is currently the trim and which is the wall.

Two things follow immediately. Repainting the whole house is one edit to the card. And you can have a second card for the north-facing rooms, where the same room needs different tins to look right — without touching a single line of the job sheet.

is the good job sheet. Its cards are called colour schemes, and there are always two: one for daylight and one for the dark.

Roles, not colours

A Material 3 is a fixed set of named slots. You fill in the slots; you never name a colour at the point of use.

The main ones, and what each is genuinely for:

RoleWhere it belongs
primaryThe app's signature colour. Main buttons, the loudest thing on screen
secondaryA supporting accent, used less often
tertiaryA third accent, for contrast against the other two
errorAnything wrong: invalid fields, delete confirmations
backgroundBehind the whole screen
surfaceWhat cards, sheets and bars are painted on
surfaceVariantA slightly different surface, for gentle separation
outlineBorders, dividers, the quietest text on the screen

Each of those loud four also comes with a container version — primaryContainer, secondaryContainer and so on. The difference is about how much of the screen it covers:

  • primary is strong. Use it for small, important areas: a filled button, an icon, one number.
  • primaryContainer is the same hue, much softer. Use it for large areas: a panel, a chip, the circle behind an icon.

A whole screen painted in primary is a screen that shouts. That is why Dice Duel's score panels use secondaryContainer and tertiaryContainer for the panel itself, and save secondary and tertiary for the big number inside.

The on rule, which removes a whole class of bug

Every colour slot has a partner whose name starts with on. primary has onPrimary. surface has onSurface. errorContainer has onErrorContainer.

An means: the colour to draw things on top of that one. It is not a suggestion. Whoever built the scheme has already checked that the pair has enough to be readable.

So the rule is mechanical, and you should follow it without thinking:

kotlin
1Surface(color = colors.primaryContainer) {
2    Text(
3        text = "PLAYER 1",
4        color = colors.onPrimaryContainer
5    )
6}

Background from a slot, text from that slot's on partner. Every time. The moment you pick a text colour by eye — Color.White on a container, because it looked fine on your phone — you have opted out of the guarantee, and somebody with the phone on minimum brightness in daylight will pay for it.

Careful

onPrimary does not mean "white". In Pocket Notes' dark scheme, primary is a light honey (0xFFFFB959) and onPrimary is a dark brown (0xFF4A2800) — because dark-mode buttons are light buttons with dark lettering. Assume white and your dark mode has invisible button text.

Two schemes, because dark mode is a redesign

Here is Dice Duel's palette. Note that these are just named holding numbers — nothing clever yet.

ui/theme/Color.ktkotlin
1val Violet = Color(0xFF5B3FD6)
2val VioletLight = Color(0xFFC3B2FF)
3val VioletPale = Color(0xFFE4DDFF)
4val VioletDeep = Color(0xFF422F9E)
5val VioletInk = Color(0xFF231152)
6
7val Rose = Color(0xFFD6355F)
8val RosePop = Color(0xFFFF87A6)
9val RosePale = Color(0xFFFFD9E2)
10val RoseDeep = Color(0xFF5C1730)
11val RoseInk = Color(0xFF5F0F27)

And here is where those tins get assigned to roles — twice.

ui/theme/Theme.ktkotlin
1private val LightColors = lightColorScheme(
2    primary = Violet,
3    onPrimary = Color.White,
4    primaryContainer = VioletPale,
5    onPrimaryContainer = VioletInk,
6    secondary = Rose,
7    onSecondary = Color.White,
8    secondaryContainer = RosePale,
9    onSecondaryContainer = RoseInk,
10    background = DayBg,
11    onBackground = DayText,
12    surface = DayCard,
13    onSurface = DayText,
14    surfaceVariant = DayTint,
15    onSurfaceVariant = DayMuted,
16    outline = DayLine
17)
18
19private val DarkColors = darkColorScheme(
20    primary = VioletLight,
21    onPrimary = VioletInk,
22    primaryContainer = VioletDeep,
23    onPrimaryContainer = VioletPale,
24    secondary = RosePop,
25    onSecondary = RoseInk,
26    secondaryContainer = RoseDeep,
27    onSecondaryContainer = RosePale,
28    background = NightBg,
29    onBackground = NightText,
30    surface = NightCard,
31    onSurface = NightText,
32    surfaceVariant = NightTint,
33    onSurfaceVariant = NightMuted,
34    outline = NightLine
35)

Read the two primary lines together and the whole idea of dark mode is right there.

In daylight, primary is Violet — deep, saturated, strong against a near-white background. In the dark it is VioletLight — pale and washed out, because a deep violet on a near-black background is a smudge. And onPrimary swaps from white to VioletInk to match.

That is why a is not an inversion. Nothing is being flipped; a second set of choices is being made. The pairs even trade places: the container colours go from pale (VioletPale) in the light scheme to deep (VioletDeep) in the dark one, and their on partners go the opposite way.

Tip

You do not have to fill in every slot. lightColorScheme() and darkColorScheme() have a sensible Material default for every parameter, so the ones you leave out still work — they just will not be yours. Both Dice Duel and Pocket Notes fill in only the roles they actually use.

Handing the scheme to the app

ui/theme/Theme.ktkotlin
1@Composable
2fun DiceDuelTheme(
3    darkTheme: Boolean = isSystemInDarkTheme(),
4    content: @Composable () -> Unit
5) {
6    val colors = if (darkTheme) DarkColors else LightColors
7    MaterialTheme(
8        colorScheme = colors,
9        content = content
10    )
11}

Then, once, at the very top of the app:

MainActivity.ktkotlin
1setContent {
2    DiceDuelTheme {
3        GameScreen()
4    }
5}

Reading the theme back

Anywhere below MaterialTheme, in any composable, at any depth:

kotlin
1val scheme = MaterialTheme.colorScheme
2
3Text(
4    text = "DICE DUEL",
5    color = scheme.primary
6)

Three things live on , and you have met two of them already:

  • MaterialTheme.colorScheme — the colours, as above.
  • MaterialTheme.typography — the type scale from Lesson 3.7. Set it with the typography parameter, exactly like colorScheme.
  • MaterialTheme.shapes — five corner radii, from extraSmall to extraLarge, used by cards, buttons, sheets and dialogs. Pocket Notes rounds them all off more than Material's default, which is a large part of why its cards read as paper.

They work by a mechanism called a : a value published at one point in the tree and readable by everything below it, without being passed as a parameter. That is normally a bad idea — invisible dependencies are hard to follow — but for the three things that every single composable in an app needs, it is exactly right.

There is one consequence to remember. Reading MaterialTheme.colorScheme only works inside a composable, because it has to know where in the tree you are asking from. Put it in a top-level val and the compiler stops you:

text
1e: @Composable invocations can only happen from the
2context of a @Composable function

Dynamic colour

Android 12 and newer can build a whole scheme from the user's wallpaper. Two lines get you it:

kotlin
1val colors = when {
2    Build.VERSION.SDK_INT >= 31 && dynamic ->
3        if (darkTheme) {
4            dynamicDarkColorScheme(context)
5        } else {
6            dynamicLightColorScheme(context)
7        }
8    darkTheme -> DarkColors
9    else -> LightColors
10}

is lovely, and it is the wrong choice for both apps in this course. In Dice Duel, rose is player one and teal is player two — that is how you tell at a glance whose turn it is. Let the wallpaper repaint them and two identical panels appear.

The honest test is: does colour carry meaning in your app? If it does, keep your own scheme. If your colours are decoration, dynamic colour is a free win that makes the app feel like it belongs on that person's phone.

The Build.VERSION.SDK_INT >= 31 check is not optional. minSdk is 26 in these projects, so calling those functions unguarded gives you a build error naming the exact API level.

The surface family

Newer Material 3 adds a set of graded surfaces, and Pocket Notes uses them heavily:

  • surfaceContainerLowest
  • surfaceContainerLow
  • surfaceContainer
  • surfaceContainerHigh
  • surfaceContainerHighest

They are five steps of "how far forward does this sit". A is how you separate a card from the page in dark mode, where a drop shadow is invisible and has to be expressed as a lighter colour instead. Pocket Notes paints its note cards in surfaceContainerLow with zero elevation, and they read as raised in both schemes.

9:41▲ ▮
DICE DUEL
First to 30 wins the duel
PLAYER 1
18
PLAYER 2
13
PLAYER 1 - TAP TO ROLL

Dice Duel with the phone in dark mode. Same code, second colour scheme.

Compare that with the light version in Lesson 3.4. Not one line of GameScreen.kt is different between the two pictures. Every colour on the screen came from a role.

Try it in Pocket Studio
  1. Open ComposeLab, tap Editor, and create a new file in the same folder as MainActivity.kt called Theme.kt.
  2. Type the LightColors and DarkColors schemes from this lesson into it, plus the DiceDuelTheme composable. Rename it LabTheme. You will need the colour vals too — copy the violets and roses from the palette above, or invent your own.
  3. Accept the imports for androidx.compose.material3.lightColorScheme, darkColorScheme, MaterialTheme and androidx.compose.foundation.isSystemInDarkTheme.
  4. In MainActivity.kt, wrap whatever is inside setContent in LabTheme { ... }.
  5. Inside it, put a Text with color = MaterialTheme.colorScheme.primary and a Surface with color = MaterialTheme.colorScheme.primaryContainer around another Text coloured onPrimaryContainer. Tap Run.
  6. Now pull down the phone's quick settings and turn Dark theme on. Come back to the app. Both texts have changed colour and both are still readable. You did not write a line for it.
  7. Break the rule on purpose: change the inner text colour to Color.White. Run in dark mode — fine. Switch to light mode — white on pale violet, almost gone. Put onPrimaryContainer back.
  8. Delete the LabTheme { } wrapper from setContent and Run. Everything turns Material's default purple, because with no theme above them the roles fall back to the baseline scheme. Put it back.
  9. Change one line — primary = Violet to primary = Rose — and Run. The whole app follows.
Error Doctor5 common errors
e: @Composable invocations can only happen from the context of a @Composable function
MeansYou read MaterialTheme.colorScheme (or .typography, or .shapes) somewhere that is not a composable — a top-level val, a plain function, or inside a ViewModel. The theme is published into the composition, so it can only be read from inside one.
FixMove the read into the composable that needs it, usually as val colors = MaterialTheme.colorScheme on its first line. If a helper function needs a colour, pass the Color in as a parameter rather than reaching for the theme.
e: Cannot find a parameter with this name: surfaceContainerLow
MeansThat slot was added to Material 3 later than the version you are building against. The named argument does not exist, so Kotlin cannot match it.
FixCheck the Compose BOM in app/build.gradle.kts — this course uses 2024.12.01, which has the whole surface container family. If you are on an older BOM, either update it or use surfaceVariant, which has always existed.
Call requires API level 31 (current min is 26): androidx.compose.material3.dynamicDarkColorScheme
MeansDynamic colour only exists on Android 12 and newer. Your app declares minSdk = 26, so it can be installed on phones where that function is simply not present — and calling it there would crash.
FixGuard the call: if (Build.VERSION.SDK_INT >= 31) { ... }. The build tools recognise that check and stop complaining. Never silence it with an annotation unless you have raised minSdk.
Everything in my app is Material's default purple, even though I wrote a scheme
MeansNo MaterialTheme is above the screen being drawn, so every role falls back to Material's baseline colours. Usually the theme wrapper is missing from setContent, or a second setContent was added without it.
FixWrap the content: setContent { DiceDuelTheme { GameScreen() } }. If only the @Preview is wrong, wrap the preview body in the theme too — a preview has no Activity, so it gets nothing for free.
In dark mode my text is invisible, but the background changed correctly
MeansThe background came from a role and followed the scheme; the text colour is a hard-coded Color(0xFF...) or a Color.Black that cannot follow anything.
FixReplace it with the matching on colour for whatever it sits on: text on surface uses onSurface, text on primaryContainer uses onPrimaryContainer. Searching your project for Color(0xFF is a fast way to find every remaining one.
Recap
  • Colour a widget by its role, never by its hex code. One edit to the scheme repaints the whole app.
  • Every slot has an meant for whatever is drawn on top of it. Use the pair and readable contrast is guaranteed.
  • primary is for small loud areas; primaryContainer is the soft version for large ones.
  • and darkColorScheme() are two independent sets of choices, not an inversion. Deep colours in daylight often become pale ones in the dark.
  • MaterialTheme(colorScheme, typography, shapes) { } publishes all three to everything below it, via a . Wrap setContent — and your previews.
  • Read them back as MaterialTheme.colorScheme.primary, and only from inside a composable.
  • as a default argument follows the phone but still lets you force a scheme.
  • is a free win when colour is decoration, and wrong when colour carries meaning. It needs an API 31 check.
  • The family is how a card looks raised in dark mode, where shadows do not show.
  • Next: images, icons and shapes — the three different ways to get a picture on screen, and which one to reach for.