Material 3 theming and colour
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:
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 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:
| Role | Where it belongs |
|---|---|
primary | The app's signature colour. Main buttons, the loudest thing on screen |
secondary | A supporting accent, used less often |
tertiary | A third accent, for contrast against the other two |
error | Anything wrong: invalid fields, delete confirmations |
background | Behind the whole screen |
surface | What cards, sheets and bars are painted on |
surfaceVariant | A slightly different surface, for gentle separation |
outline | Borders, 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:
primaryis strong. Use it for small, important areas: a filled button, an icon, one number.primaryContaineris 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:
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.
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.
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.
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.
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
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:
1setContent {
2 DiceDuelTheme {
3 GameScreen()
4 }
5}Reading the theme back
Anywhere below MaterialTheme, in any composable, at any depth:
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 thetypographyparameter, exactly likecolorScheme.MaterialTheme.shapes— five corner radii, fromextraSmalltoextraLarge, 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:
1e: @Composable invocations can only happen from the
2context of a @Composable functionDynamic colour
Android 12 and newer can build a whole scheme from the user's wallpaper. Two lines get you it:
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:
surfaceContainerLowestsurfaceContainerLowsurfaceContainersurfaceContainerHighsurfaceContainerHighest
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.
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.
- Open ComposeLab, tap Editor, and create a new file in the same folder as
MainActivity.ktcalledTheme.kt. - Type the
LightColorsandDarkColorsschemes from this lesson into it, plus theDiceDuelThemecomposable. Rename itLabTheme. You will need the colourvals too — copy the violets and roses from the palette above, or invent your own. - Accept the imports for
androidx.compose.material3.lightColorScheme,darkColorScheme,MaterialThemeandandroidx.compose.foundation.isSystemInDarkTheme. - In
MainActivity.kt, wrap whatever is insidesetContentinLabTheme { ... }. - Inside it, put a
Textwithcolor = MaterialTheme.colorScheme.primaryand aSurfacewithcolor = MaterialTheme.colorScheme.primaryContaineraround anotherTextcolouredonPrimaryContainer. Tap Run. - 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.
- 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. PutonPrimaryContainerback. - Delete the
LabTheme { }wrapper fromsetContentand Run. Everything turns Material's default purple, because with no theme above them the roles fall back to the baseline scheme. Put it back. - Change one line —
primary = Violettoprimary = Rose— and Run. The whole app follows.
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.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.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.minSdk = 26, so it can be installed on phones where that function is simply not present — and calling it there would crash.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.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.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.Color(0xFF...) or a Color.Black that cannot follow anything.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.- 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.
primaryis for small loud areas;primaryContaineris 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 . WrapsetContent— 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.