Material theming, colour and typography
Mandi Bhav has been wearing someone else's clothes. The bare MaterialTheme from module 1 uses
Material's baseline scheme, and on the emulator the module 3 build showed it: a faintly lavender
background, greyish-lilac cards, and the app bar's title in the same plain black as everything
else. A farmer who uses the app every morning should recognise it at a glance, and a price should
be the first thing the eye lands on. This lesson gives the app its own theme in three files,
Color.kt, Type.kt and Theme.kt, and changes every composable to take its colours and text
styles from it.
What MaterialTheme holds
MaterialTheme provides three things to everything inside it:
| Part | Type | What reads it |
|---|---|---|
colorScheme |
ColorScheme, a few dozen named colour roles |
Every Material component, and your code through MaterialTheme.colorScheme |
typography |
Typography, the 15 styles of the type scale |
Text defaults, components, and MaterialTheme.typography |
shapes |
Shapes, corner sizes from extra small to extra large |
Cards, chips, buttons, dialogs |
The values are passed down the tree invisibly, as a composition local: any composable inside
the theme can read MaterialTheme.colorScheme.primary and gets the value from the nearest
MaterialTheme above it. That is why a theme change reaches every screen without a single
parameter being added.
Components do not take "a colour"; they take a role. On the emulator the cards are
surfaceContainerHighest, the selected filter chip is secondaryContainer, the search field's
border is outline, and the screen behind everything is surface. You change the role's value
once, and every component using it follows.
The colour roles you will use
| Role | Meaning | In Mandi Bhav |
|---|---|---|
primary / onPrimary |
The brand colour, and text or icons drawn on it | The modal price; filled buttons later |
primaryContainer / onPrimaryContainer |
A softer tint of the brand for larger areas | Highlighted cards later |
secondary, secondaryContainer and their on colours |
A quieter accent | The selected filter chip |
tertiary, tertiaryContainer and their on colours |
A contrasting accent | Badges such as "Lowest" |
surface / onSurface |
The background of the screen and its main text | Screen background, titles |
onSurfaceVariant |
Secondary text on a surface | Market, date, "per quintal" |
surfaceContainerLowest … surfaceContainerHighest |
Five steps of surface for things that sit on the background | Cards use the highest |
outline / outlineVariant |
Borders and dividers | Chip and text field borders |
error / onError |
Errors | The networking module's failure messages |
The rule that matters: every on colour is designed for the role it is named after.
onPrimary text on a primary background is guaranteed readable; onPrimary on surface is not.
Lesson 7 measures what happens when that pairing is wrong.
The palette
A Material scheme is built from tonal palettes: one hue taken from very dark (tone 10) to very
light (tone 90 and above). A light scheme uses tone 40 for primary and tone 90 for its
container; a dark scheme flips to tone 80 and tone 30. The names below follow that convention, so
Green40 is the light scheme's primary.
The values here were picked by hand around the launcher icon's green, #2E7D32, with a muted
sage as the secondary, turmeric as the tertiary, and neutrals with a slight green tint so the
backgrounds belong to the same family. For a client's brand, generate the whole set from their
colour with Material Theme Builder instead: it computes every tone and exports a Color.kt and
Theme.kt you can paste in.
Create the package ui.theme (right-click ui, New > Package) and in it Color.kt:
package com.riztechacademy.mandibhav.ui.theme
import androidx.compose.ui.graphics.Color
// Mandi Bhav's palette: a field green for the brand, a turmeric accent, and
// neutrals with a slight green tint so surfaces belong to the same family.
// Light scheme
val Green40 = Color(0xFF2E7D32)
val Green90 = Color(0xFFB8F0B0)
val Green10 = Color(0xFF002106)
val Sage40 = Color(0xFF52634F)
val Sage90 = Color(0xFFD5E8CF)
val Sage10 = Color(0xFF101F0F)
val Turmeric40 = Color(0xFF8A5100)
val Turmeric90 = Color(0xFFFFDCBE)
val Turmeric10 = Color(0xFF2C1600)
val NeutralLight = Color(0xFFF7FBF2)
val OnNeutralLight = Color(0xFF181D17)
val NeutralVariantLight = Color(0xFFDEE5D8)
val OnNeutralVariantLight = Color(0xFF424940)
val OutlineLight = Color(0xFF72796F)
val OutlineVariantLight = Color(0xFFC2C9BD)
val ContainerLowestLight = Color(0xFFFFFFFF)
val ContainerLowLight = Color(0xFFF1F5EC)
val ContainerLight = Color(0xFFEBEFE6)
val ContainerHighLight = Color(0xFFE6E9E1)
val ContainerHighestLight = Color(0xFFE0E4DB)
val SurfaceDimLight = Color(0xFFD7DBD3)
// Dark scheme
val Green80 = Color(0xFF9CD69A)
val Green20 = Color(0xFF00390F)
val Green30 = Color(0xFF1B5E20)
val Sage80 = Color(0xFFB9CCB4)
val Sage20 = Color(0xFF253423)
val Sage30 = Color(0xFF3B4B38)
val Turmeric80 = Color(0xFFFFB870)
val Turmeric20 = Color(0xFF4A2800)
val Turmeric30 = Color(0xFF693C00)
val NeutralDark = Color(0xFF101510)
val OnNeutralDark = Color(0xFFE0E4DB)
val NeutralVariantDark = Color(0xFF424940)
val OnNeutralVariantDark = Color(0xFFC2C9BD)
val OutlineDark = Color(0xFF8C9388)
val OutlineVariantDark = Color(0xFF424940)
val ContainerLowestDark = Color(0xFF0B0F0A)
val ContainerLowDark = Color(0xFF181D17)
val ContainerDark = Color(0xFF1C211B)
val ContainerHighDark = Color(0xFF272B25)
val ContainerHighestDark = Color(0xFF313630)
val SurfaceBrightDark = Color(0xFF363A34)
The dark values are here now so the file is written once; lesson 5 uses them. This is the only file in the app allowed to contain a hex colour.
Typography
The Material type scale is fifteen styles: display, headline, title, body and label, each large,
medium and small. The default values are sensible. Change only what has a reason. Type.kt:
package com.riztechacademy.mandibhav.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.font.FontWeight
private val Base = Typography()
/**
* The Material type scale with three changes. The system font is kept: it is
* already on the phone, covers Devanagari, and costs nothing to download.
*/
val MandiBhavTypography = Base.copy(
// Card titles: a little heavier, so the commodity is the first thing read.
titleLarge = Base.titleLarge.copy(fontWeight = FontWeight.SemiBold),
// The modal price. Tabular figures give every digit the same width,
// so prices on cards one above the other line up digit for digit.
headlineSmall = Base.headlineSmall.copy(
fontWeight = FontWeight.SemiBold,
fontFeatureSettings = "tnum",
),
// Min and max under the modal price use the same figures.
bodyMedium = Base.bodyMedium.copy(fontFeatureSettings = "tnum"),
)
Typography and TextStyle are immutable, so you change them with copy, which returns a new
value with the named fields replaced.
"tnum" asks the font for tabular figures, digits that all have the same width, so that
₹1,111 and ₹8,888 are the same length and prices stacked on cards line up. Many system fonts,
Roboto among them, already draw equal-width digits; some manufacturers' fonts do not. Where the
font already does it, the setting changes nothing and costs nothing.
Why no custom font. A bundled font in res/font adds its file size to every install, and a
font with full Devanagari coverage is large; a downloadable font needs the network on first launch,
which is the thing Mandi Bhav's users have least of. The system font is already on the phone, is
the one the user's eyes are used to, and has Devanagari for when the app is translated. A brand
that insists on its own typeface can have it; know that this is the price.
The theme
Theme.kt, light scheme only for now:
package com.riztechacademy.mandibhav.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColors = lightColorScheme(
primary = Green40,
onPrimary = Color.White,
primaryContainer = Green90,
onPrimaryContainer = Green10,
inversePrimary = Green80,
secondary = Sage40,
onSecondary = Color.White,
secondaryContainer = Sage90,
onSecondaryContainer = Sage10,
tertiary = Turmeric40,
onTertiary = Color.White,
tertiaryContainer = Turmeric90,
onTertiaryContainer = Turmeric10,
background = NeutralLight,
onBackground = OnNeutralLight,
surface = NeutralLight,
onSurface = OnNeutralLight,
surfaceVariant = NeutralVariantLight,
onSurfaceVariant = OnNeutralVariantLight,
inverseSurface = OnNeutralLight,
inverseOnSurface = ContainerLight,
outline = OutlineLight,
outlineVariant = OutlineVariantLight,
surfaceBright = NeutralLight,
surfaceDim = SurfaceDimLight,
surfaceContainerLowest = ContainerLowestLight,
surfaceContainerLow = ContainerLowLight,
surfaceContainer = ContainerLight,
surfaceContainerHigh = ContainerHighLight,
surfaceContainerHighest = ContainerHighestLight,
)
@Composable
fun MandiBhavTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = LightColors,
typography = MandiBhavTypography,
content = content,
)
}
lightColorScheme takes every role as a parameter with a default, so you name the ones you set.
MandiBhavTheme is an ordinary composable that wraps MaterialTheme; the rest of the app never
mentions MaterialTheme(…) again, only reads from MaterialTheme.colorScheme and
MaterialTheme.typography.
Using it
In MainActivity, replace MaterialTheme { with MandiBhavTheme {, import
com.riztechacademy.mandibhav.ui.theme.MandiBhavTheme, and remove the MaterialTheme import. Do
the same in every preview in PriceCard.kt and PricesScreen.kt, so previews show the real
theme; module 3 warned that a preview in the wrong theme stops matching the app.
Then make the price the brand colour. In PriceCard, the modal price Text gains one line:
Text(
text = formatRupees(price.modalPrice),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.alignByBaseline(),
)
Run it. The background is a pale green-white, the cards a soft grey-green, the selected "All"
chip a light sage, and each modal price a semibold green. Nothing in PriceCard or PricesScreen
names a colour value; they name roles.
The trap: a hex colour in a composable
Text(text = formatRupees(price.modalPrice), color = Color(0xFF2E7D32))
It compiles, it looks identical today, and it is a bug waiting for lesson 5. A hard-coded colour
does not know about the theme, so when dark mode arrives it stays the same dark green on a
near-black card. The same goes for Color.Black text and Color.White backgrounds: they are
the commonest cause of an app that looks right in light mode and broken in dark mode. Lint does
not flag it. Code review has to, which is why the rule above is worth holding to: hex values live
in Color.kt, roles everywhere else.
The other trap is the half-set scheme. Set only primary and leave the rest to defaults, and the
defaults are Material's baseline, lavender-tinted values: green prices on lilac cards. Set every
surface and container role, or none.
What a theme costs: about a hundred lines you maintain, and a decision every time a designer asks for "a slightly different green here". The answer is almost always a different role, not a new colour.
Check your work
The three parts of MaterialTheme. Colour scheme, typography and shapes.
How a composable deep in the tree finds the theme. It reads a composition local that the
nearest MaterialTheme above it provides.
Which role the cards use, and which the selected chip uses. surfaceContainerHighest and
secondaryContainer.
Which tone a light scheme uses for primary. Tone 40; the dark scheme uses 80.
What "tnum" does. Asks for equal-width digits so numbers in a column line up.
Why the app keeps the system font. No extra download or install size, and it already covers Devanagari.
Why Color(0xFF2E7D32) inside a composable is a bug. It ignores the theme, so it does not
change in dark mode or with any future theme change.
Practice 2. The cards and background go back to Material's baseline lavender-grey while the prices stay green, because every role you did not name takes the baseline default.
Practice 4. MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold) at the
call site works, but it puts a style decision in one composable; change headlineSmall in
Type.kt instead if every price should be bold.
Practice
- Create the three theme files, switch
MainActivityand the previews toMandiBhavTheme, and run it. Put a module 3 screenshot next to it. - In
LightColors, comment out everything exceptprimaryandonPrimary. Run it, describe what you see, then restore it. - Change
titleLargeinType.kttoFontWeight.Boldand look at every preview at once. Decide whether you prefer it, and put it back if not. - Make the modal price bold for one card only, without touching
Type.kt. Then explain why you would usually not do it that way. - Break it on purpose: give the "per quintal" text
color = Color.Gray. Keep it until lesson 5 and see what dark mode does to it. - Harder: open Material Theme Builder, enter
#2E7D32as the source colour, and export for Jetpack Compose. Compare itsprimary,surfaceContainerHighestandonSurfaceVariantwith the values here, and say which you would ship and why.
Official documentation
- Material Design 3 in Compose — Colour schemes, typography, shapes and custom themes in code.
- Material 3 colour roles — What each role is for, with the pairings that must not be mixed.
- Material 3 type scale tokens — The default size, weight and line height of every style.
- Material Theme Builder — Generates a complete light and dark scheme from one brand colour.
- Working with fonts — Bundled and downloadable fonts, and font features.
Next: dark mode, and whether a brand app should let the wallpaper choose its colours.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship