Dark theme and contrast
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 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:
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.
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:
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:
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:
| Role | Dark value | Used for |
|---|---|---|
| surface | #17110E | The page itself |
| surfaceContainerLow | #201A16 | Note cards, search field |
| surfaceContainer | #241E1A | The app bar once scrolled |
| surfaceContainerHigh | #2F2824 | Menus and sheets |
| surfaceContainerHighest | #3A332E | The 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:
| Pair | Ratio | Verdict |
|---|---|---|
| Light: onSurface on surface | 16.7:1 | Comfortable |
| Light: onSurfaceVariant on surface | 9.0:1 | Comfortable |
| Dark: onSurface on surface | 14.4:1 | Comfortable |
| Dark: onSurfaceVariant on surface | 11.0:1 | Comfortable |
Dark: primary #FFB959 on surface | 11.0:1 | Comfortable |
Light primary #8A5100 on dark surface | 2.9:1 | Fails |
Grey #999999 on white | 2.9:1 | Fails |
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.
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
Pocket Notes in light mode. Page #FFFBF7, cards #FFF3E9, button #8A5100.
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:
1<style
2 name="Theme.PocketNotes"
3 parent="android:Theme.Material.Light.NoActionBar">
4 <item name="android:windowBackground">#FFFBF7</item>
5</style>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.
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.
- Open Pocket Studio → Projects → Pocket Notes. Tap Build, then Run ▶. Write two notes so the list is not empty.
- 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.
- Back in Pocket Studio, tap Editor and open
app/src/main/java/.../ui/NoteCard.kt. - Find the
Textthat draws the note title and replace itscolor = ...withcolor = Color(0xFF211A13). Addimport androidx.compose.ui.graphics.Colorif the editor asks. - Tap Run. In light mode it looks completely unchanged — because you typed the light scheme's own colour.
- 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.
- Put it back to
color = colors.onSurfaceand Run again. - Now break the other half. Open
ui/theme/Theme.ktand inDarkColorschangeprimary = Color(0xFFFFB959)toprimary = Color(0xFF8A5100)— the light scheme's brown. - 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.
- 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.
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.app/build.gradle.kts uses the Compose BOM 2024.12.01 these apps are built against, which brings a Material 3 that has them.MaterialTheme with a Material 3 colour scheme. Both libraries have a composable called MaterialTheme, and the editor's auto-import picked the wrong one.androidx.compose.material3.MaterialTheme, not androidx.compose.material.MaterialTheme. Check the whole import block — the same trap catches Text, Button, Card and Icon.foundation rather than material3, which is why the editor often does not offer it.import androidx.compose.foundation.isSystemInDarkTheme.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.window_background in res/values/colors.xml first, then give it a different value in res/values-night/colors.xml.windowBackground. Your Compose colours are correct; they just have not been applied yet.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.- 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.