Pocket Studio Academy
HomePart 66.4

Dark theme and contrast

Full course10 min read·4 questions

All three apps already switch to dark automatically, and none of them do it by inverting anything. This lesson opens the two real colour schemes, explains what a contrast ratio actually measures, and shows the numbers your apps score.

You already shipped this, twice

Put your phone in dark mode and open Pocket Notes. The page goes near-black, the text goes warm cream, the round + button changes from deep brown to bright amber. Nothing flashes, nothing is unreadable, and you wrote no if statements to make it happen.

That works because Theme.kt contains two complete colour schemes, not one scheme and a switch. This lesson is about why it has to be two, and about the number that tells you whether either of them is any good.

Think of it like this

Think about printing a poster.

Black ink on white paper: thin letters read perfectly, and a hairline rule looks crisp.

Now print the identical artwork as white ink on black card. The same thin letters look thinner, because ink spreads slightly and the black around them closes in. The hairline rule half disappears. A printer who cared would not send you the same file — they would set the type a weight heavier and thicken the rule.

Dark mode is that. Not the same design flipped, but the same design remade for a surface that behaves differently. A colour that was a strong accent on white can vanish on near-black, and a grey that was a gentle hint becomes a shout.

Which is why darkColorScheme is a separate list of colours, typed out in full, rather than a function that inverts the light one.

One theme, two schemes

Here is the real switch, at the bottom of Pocket Notes' theme file.

And the two schemes it chooses between, trimmed to the slots worth comparing:

ui/theme/Theme.ktkotlin
1private val LightColors = lightColorScheme(
2    primary = Color(0xFF8A5100),
3    onPrimary = Color(0xFFFFFFFF),
4    surface = Color(0xFFFFFBF7),
5    onSurface = Color(0xFF211A13),
6    onSurfaceVariant = Color(0xFF514538),
7    surfaceContainer = Color(0xFFFDEDE2),
8    // ...and about thirty more
9)
10
11private val DarkColors = darkColorScheme(
12    primary = Color(0xFFFFB959),
13    onPrimary = Color(0xFF4A2800),
14    surface = Color(0xFF17110E),
15    onSurface = Color(0xFFEDE0D4),
16    onSurfaceVariant = Color(0xFFD5C3B4),
17    surfaceContainer = Color(0xFF241E1A),
18    // ...and about thirty more
19)

Look at primary. Light is #8A5100, a deep brown. Dark is #FFB959, a bright amber. Those are not inversions of each other — they are the same idea, honey, at two very different brightnesses, because the thing behind them changed completely.

The "on" rule

Notice that every colour has a partner. primary has onPrimary. surface has onSurface. That pairing is the most useful convention in Material 3, and it is not decorative: is the colour guaranteed to be readable when painted on top of its partner.

So the rule for writing a component is: pick the container colour, then use its partner for anything on top.

ui/NoteListScreen.ktkotlin
1FloatingActionButton(
2    onClick = onNewNote,
3    containerColor = colors.primary,
4    contentColor = colors.onPrimary
5)

In light mode that is white on deep brown. In dark mode it is dark brown on amber. One piece of code, two correct results, and you never had to think about either.

Why you do not type a colour where you use it

The rule people repeat is "never hardcode Color.White". It is nearly right, and the reason matters more than the rule.

Pocket Notes' light scheme literally contains onPrimary = Color(0xFFFFFFFF). Dice Duel's is even blunter — onPrimary = Color.White. Neither is a bug. They sit inside lightColorScheme(...), where "white" is a considered answer to "what goes on top of violet in daylight", and the dark scheme answers the same question separately with VioletInk.

The bug is typing a colour at the point of use:

do not do thiskotlin
1Text(
2    text = note.title,
3    color = Color(0xFF211A13)
4)

That is the light scheme's onSurface, frozen. In dark mode the surface behind it becomes #17110E and the text is near-black on near-black — a of 1.1:1, which is to say invisible. Nothing warns you. It builds, it runs, and it is unreadable for every user who prefers dark mode.

There is exactly one honest reason to hardcode, and Dice Duel has it:

ui/theme/Color.ktkotlin
1// A real die is white with dark pips, in any light.
2val DieFaceLight = Color(0xFFFDFBFF)
3val DiePipDark = Color(0xFF241A4D)

The die is a physical object with a real colour. Dice do not go dark at night. When your colour is a fact about the world rather than a choice about your UI, name it, comment why, and keep it out of both schemes.

Cards in the dark: tonal elevation

In daylight, a card floats above the page because of a shadow. On a near-black background a shadow is invisible — black on black.

Material 3's answer is : instead of a shadow, a raised surface gets a slightly lighter colour. That is what the family is for. Pocket Notes' dark scheme, from the back of the screen forwards:

RoleDark valueUsed for
surface#17110EThe page itself
surfaceContainerLow#201A16Note cards, search field
surfaceContainer#241E1AThe app bar once scrolled
surfaceContainerHigh#2F2824Menus and sheets
surfaceContainerHighest#3A332EThe most raised thing on screen

Those steps are tiny — the contrast between the page and a card on it is about 1.1:1. That is deliberate. You are not trying to make the card readable against the page; you are trying to make its edge findable. Push the steps further apart and a list of cards turns into a stripey mess.

The rule of thumb: surface for the page, surfaceContainerLow for things resting on it, and each step up as things move towards the front.

What a contrast ratio actually is

Every colour has a — how much light it puts out, from 0 for black to 1 for white. A contrast ratio compares the luminance of two colours and produces a number between 1:1 (identical, invisible) and 21:1 (pure black on pure white).

The thresholds come from , level AA:

  • 4.5:1 for normal body text.
  • 3:1 for large text — roughly 18sp and up, or 14sp bold — and for the edges of controls like a switch track or a text field outline.

You cannot judge this by eye. Two colours that look obviously different can fail, and that is the whole reason the number exists. Here is how the real schemes score:

PairRatioVerdict
Light: onSurface on surface16.7:1Comfortable
Light: onSurfaceVariant on surface9.0:1Comfortable
Dark: onSurface on surface14.4:1Comfortable
Dark: onSurfaceVariant on surface11.0:1Comfortable
Dark: primary #FFB959 on surface11.0:1Comfortable
Light primary #8A5100 on dark surface2.9:1Fails
Grey #999999 on white2.9:1Fails

The sixth row is the point of this entire lesson. #8A5100 is a perfectly good accent colour — it scores 6.3:1 on the light page it was designed for. Move that exact colour onto the dark page without changing it and it drops to 2.9:1, below the threshold for body text, and someone reading in bed cannot make out your button.

The last row is the classic web mistake, sitting at the same score, which should tell you how easy it is to make.

Careful

onSurfaceVariant is where contrast bugs live. It is the muted colour — timestamps, captions, placeholder text — so it is the colour a designer is most tempted to lighten "just a bit" for elegance. Both of Pocket Notes' land near 9:1 and 11:1, which is generous on purpose. Below 4.5:1 it is not subtle, it is unreadable.

The same screen, both ways

9:41▲ ▮
Pocket Notes
Search notes
Shopping
Oat milk, tomatoes, bread
Just now
Ideas
A timer that turns the lights down when a session starts…
3 h ago
+

Pocket Notes in light mode. Page #FFFBF7, cards #FFF3E9, button #8A5100.

9:41▲ ▮
Pocket Notes
Search notes
Shopping
Oat milk, tomatoes, bread
Just now
Ideas
A timer that turns the lights down when a session starts…
3 h ago
+

The identical screen in dark mode. Page #17110E, cards #201A16, button #FFB959 with dark ink on it.

Every element is in the same place. Nothing moved, nothing resized. Only the forty values in the scheme changed — and the + flipped from white-on-brown to brown-on-amber, because onPrimary did its job.

The half-second before Compose draws

There is one moment your colour scheme cannot help with. Between the launcher starting your and Compose drawing its first frame, the window already exists and has to be painted something. If that something is white, a dark-mode user gets a white flash on every launch.

That is what the XML themes are for, and why every app has two of them:

res/values/themes.xmlxml
1<style
2  name="Theme.PocketNotes"
3  parent="android:Theme.Material.Light.NoActionBar">
4  <item name="android:windowBackground">#FFFBF7</item>
5</style>
res/values-night/themes.xmlxml
1<style
2  name="Theme.PocketNotes"
3  parent="android:Theme.Material.NoActionBar">
4  <item name="android:windowBackground">#17110E</item>
5</style>

Same style name, same the manifest points at. The only difference is the folder: is a , and Android reads that folder instead whenever the phone is in dark mode. No code, no check, no if.

The two hex values are background from the light scheme and background from the dark scheme, copied by hand. They have to be typed twice because XML resources and Kotlin Color values live in two different worlds — which is exactly why the comment above them in the real file says what they are for.

Focus Flow does the same thing one step tidier: both its themes point at @color/window_background, and it is that colour which has a values-night version.

Two honest footnotes

Pure black. Dark themes are near-black (#17110E, #131318) rather than . On an screen a truly black pixel is switched off, so pure black does save a little power — but it also removes every edge, every shadow and every step of tonal elevation, and the surface stack above stops working. Material chose readability. So did your apps.

Force dark. Android can machine-invert an app that has no dark theme (). The results are unpredictable — logos go negative, photographs invert — and it does nothing at all for a Compose app. Writing the second scheme is the only real answer, and you have already done it three times.

Try it in Pocket Studio
  1. Open Pocket StudioProjectsPocket Notes. Tap Build, then Run ▶. Write two notes so the list is not empty.
  2. Swipe down your phone's quick settings and turn dark mode on and off with the app open. Watch it change under you. Nothing restarts.
  3. Back in Pocket Studio, tap Editor and open app/src/main/java/.../ui/NoteCard.kt.
  4. Find the Text that draws the note title and replace its color = ... with color = Color(0xFF211A13). Add import androidx.compose.ui.graphics.Color if the editor asks.
  5. Tap Run. In light mode it looks completely unchanged — because you typed the light scheme's own colour.
  6. Turn dark mode on. The titles have vanished. That is 1.1:1, and it is what a hardcoded colour does to half your users.
  7. Put it back to color = colors.onSurface and Run again.
  8. Now break the other half. Open ui/theme/Theme.kt and in DarkColors change primary = Color(0xFFFFB959) to primary = Color(0xFF8A5100) — the light scheme's brown.
  9. Run in dark mode and look at the + button. It is still there, still tappable, and it has almost disappeared into the page. That is 2.9:1.
  10. Put the amber back. Then delete app/src/main/res/values-night/themes.xml, Run in dark mode, and watch for a white flash as the app opens. Restore the file when you have seen it.
Error Doctor5 common errors
e: Unresolved reference: surfaceContainer
MeansThe surfaceContainer family arrived in Material 3 version 1.2. Either the project is on an older Material 3, or the slot name is misspelled — the five are surfaceContainerLowest, Low, plain, High and Highest.
FixCheck the spelling first. If it is right, check app/build.gradle.kts uses the Compose BOM 2024.12.01 these apps are built against, which brings a Material 3 that has them.
e: Type mismatch: inferred type is androidx.compose.material3.ColorScheme but androidx.compose.material.Colors was expected
MeansYou are using Material 2's MaterialTheme with a Material 3 colour scheme. Both libraries have a composable called MaterialTheme, and the editor's auto-import picked the wrong one.
FixFix the import: androidx.compose.material3.MaterialTheme, not androidx.compose.material.MaterialTheme. Check the whole import block — the same trap catches Text, Button, Card and Icon.
e: Unresolved reference: isSystemInDarkTheme
MeansThe function is not imported. It lives in foundation rather than material3, which is why the editor often does not offer it.
FixAdd import androidx.compose.foundation.isSystemInDarkTheme.
AAPT: error: resource color/window_background not found.
MeansYou created res/values-night/colors.xml but not res/values/colors.xml. A -night folder is an override, not a definition — if the plain folder has no default, there is nothing to override.
FixEvery resource must exist in the unqualified folder. Define window_background in res/values/colors.xml first, then give it a different value in res/values-night/colors.xml.
The app flashes white for a moment when it opens on a dark phone
MeansThe window is painted before Compose draws anything, and the theme it is using has a light windowBackground. Your Compose colours are correct; they just have not been applied yet.
FixAdd res/values-night/themes.xml with the same style name and a dark android:windowBackground. Match it to your dark scheme's background so the join is invisible.
Recap
  • A is a second complete , not an inversion. isSystemInDarkTheme() chooses between them in one line.
  • Every colour has an that is guaranteed to be readable on it. Pick a container colour, then use its partner for whatever sits on top.
  • Type hex values inside the schemes, never at the point of use. The exception is a colour that is a fact about the world, like the white face of a die — and it gets a comment saying so.
  • Shadows do not work on near-black, so Material raises surfaces by colour instead. That is and the stack.
  • A compares two colours' . AA wants 4.5:1 for body text and 3:1 for large text and control edges. Your schemes score between 9:1 and 17:1; the same accent moved to the wrong background scores 2.9:1.
  • gives the window a dark background for the half-second before Compose draws, which is what stops the white flash.
  • Next: debug versus release builds — what the phone has actually been installing all this time, and what changes when you build the other kind.