In lesson 22 you met theming for the first time: you opened the Theme.kt file that Android Studio generated and changed a colour or two. This lesson takes you inside that file. By the end you will understand every line of it and be able to build a theme from scratch, so that your app has its own identity instead of looking like a template.
This lesson covers three items of Pathway 2 of the Jetpack Compose for Android developers programme (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-2): the Material Design System ideas, Theme your app with Material Design 3, and Styling text. It follows the structure of the official Theming in Jetpack Compose with Material 3 codelab, but with our own example app.
By the end you will be able to:
- Explain what a design system is and why Material 3 is one.
- Build a
ColorSchemewithlightColorSchemeanddarkColorScheme, and understand the colour roles (primary, onPrimary, container, surface and so on). - Turn on dynamic colour safely on Android 12 and above, with a fallback.
- Define
TypographyandShapes, and read them withMaterialTheme. - Support light and dark theme, and make system bars match.
- Style text with
TextStyle,AnnotatedString,SpanStyle,ParagraphStyle, custom fonts, downloadable fonts and variable fonts. - Override a theme for just one part of the screen.
Big idea: never write a colour, font size or corner radius directly inside a screen. Write a role (
MaterialTheme.colorScheme.primary,MaterialTheme.typography.titleLarge). Change the theme once, and the whole app follows, including dark mode.
- Composables,
Text,Modifier(lessons 15–16). - Basic Material 3 components such as
Button,Card,Scaffold(lessons 17–21). - A
Theme.ktfile exists in your project (lesson 22 and lesson 15's project template). spis for text,dpis for everything else (lesson 15).
Imagine a school in Ibadan that prints its own exercise books. If every teacher picked their own cover colour, handwriting font and logo position, nothing would look like it came from one school. So the school writes a short style guide: "Our colour is green, headings are bold, the badge sits top left, corners are slightly rounded." Now every book looks consistent, and a new teacher does not have to guess.
A design system is that style guide for software: a shared set of rules (colour, type, shape, spacing, motion) plus ready-made components that obey the rules. Material Design 3 (often called Material You or M3) is Google's design system. In Compose, it lives in the androidx.compose.material3 library and has three pillars you control:
| Pillar | Compose type | What it controls | How you read it |
|---|---|---|---|
| Colour | ColorScheme |
A set of named colour roles | MaterialTheme.colorScheme.primary |
| Typography | Typography |
A set of named text styles | MaterialTheme.typography.bodyLarge |
| Shape | Shapes |
A set of corner sizes | MaterialTheme.shapes.medium |
All three are bundled by one composable, MaterialTheme, which makes them available to everything inside it through CompositionLocals. A Button placed inside MaterialTheme automatically uses the primary colour and the labelLarge text style. You never pass them in.
setContent {
MyAppTheme { <- wraps MaterialTheme(colorScheme, typography, shapes)
Scaffold {
Button(onClick = { }) { <- reads colorScheme.primary, shapes.full, typography.labelLarge
Text("Pay")
}
}
}
}
Roles, not colours
A beginner thinks in colours: "make the button green". Material thinks in roles: "this is the most important action". The role is then assigned a colour by the theme. The big advantage is automatic dark mode: in dark theme the role primary simply points to a lighter green, and nothing in your screens changes.
Roles come in pairs: a colour and the colour to use on top of it. This is how Material guarantees readable text.
| Role | Meaning | Text/icon colour used on it |
|---|---|---|
primary |
Key components: main buttons, active states | onPrimary |
primaryContainer |
Less prominent fills: tonal buttons, chips | onPrimaryContainer |
secondary / secondaryContainer |
Less prominent components | onSecondary / onSecondaryContainer |
tertiary / tertiaryContainer |
Accents that balance primary and secondary | onTertiary / onTertiaryContainer |
error / errorContainer |
Errors | onError / onErrorContainer |
background |
Behind all content | onBackground |
surface |
Cards, sheets, menus | onSurface |
surfaceVariant |
Alternate surfaces, input fields | onSurfaceVariant |
outline / outlineVariant |
Borders and dividers | – |
inverseSurface |
Snackbars | inverseOnSurface |
surfaceContainerLowest … surfaceContainerHighest |
A ladder of surface tones for layered UI | onSurface |
Some role names have been added or reorganised as Material 3 has matured (for example the surfaceContainer* ladder), so treat the table as the idea and check the ColorScheme reference for the exact list in the version you use.
We will theme a small app called Naija Snacks (a menu of snacks with a price). Start a new Empty Activity project named NaijaSnacks, package com.example.naijasnacks. Open ui/theme/Color.kt.
Android Studio's template gives you a few constants. Replace the file with your own palette. Colours in Compose are Color(0xAARRGGBB) values; FF means fully opaque.
package com.example.naijasnacks.ui.theme
import androidx.compose.ui.graphics.Color
// Light theme
val PrimaryLight = Color(0xFF1B6B3A)
val OnPrimaryLight = Color(0xFFFFFFFF)
val PrimaryContainerLight = Color(0xFFA8F5B7)
val OnPrimaryContainerLight = Color(0xFF00210D)
val SecondaryLight = Color(0xFF8A5100)
val OnSecondaryLight = Color(0xFFFFFFFF)
val SecondaryContainerLight = Color(0xFFFFDCBD)
val OnSecondaryContainerLight = Color(0xFF2C1600)
val BackgroundLight = Color(0xFFFCFDF7)
val OnBackgroundLight = Color(0xFF1A1C19)
val SurfaceLight = Color(0xFFFCFDF7)
val OnSurfaceLight = Color(0xFF1A1C19)
// Dark theme
val PrimaryDark = Color(0xFF8DD89C)
val OnPrimaryDark = Color(0xFF00391A)
val PrimaryContainerDark = Color(0xFF00522A)
val OnPrimaryContainerDark = Color(0xFFA8F5B7)
val SecondaryDark = Color(0xFFFFB86B)
val OnSecondaryDark = Color(0xFF4A2800)
val SecondaryContainerDark = Color(0xFF693C00)
val OnSecondaryContainerDark = Color(0xFFFFDCBD)
val BackgroundDark = Color(0xFF1A1C19)
val OnBackgroundDark = Color(0xFFE2E3DD)
val SurfaceDark = Color(0xFF1A1C19)
val OnSurfaceDark = Color(0xFFE2E3DD)
Where did these numbers come from? You do not have to invent a whole palette by hand. The Material Theme Builder (a free web tool and a Figma plugin from the Material Design team) takes one source colour and generates a harmonious light and dark palette, and can export Compose code. Search for "Material Theme Builder" to find it. Note the pattern in the output above: in light theme the main colour is dark with light text on it; in dark theme the main colour is light with dark text on it.
Now build the two schemes in Theme.kt:
package com.example.naijasnacks.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
private val LightColors: ColorScheme = lightColorScheme(
primary = PrimaryLight,
onPrimary = OnPrimaryLight,
primaryContainer = PrimaryContainerLight,
onPrimaryContainer = OnPrimaryContainerLight,
secondary = SecondaryLight,
onSecondary = OnSecondaryLight,
secondaryContainer = SecondaryContainerLight,
onSecondaryContainer = OnSecondaryContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
)
private val DarkColors: ColorScheme = darkColorScheme(
primary = PrimaryDark,
onPrimary = OnPrimaryDark,
primaryContainer = PrimaryContainerDark,
onPrimaryContainer = OnPrimaryContainerDark,
secondary = SecondaryDark,
onSecondary = OnSecondaryDark,
secondaryContainer = SecondaryContainerDark,
onSecondaryContainer = OnSecondaryContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
)
lightColorScheme(...) and darkColorScheme(...) are factory functions with defaults for every role. You override only the roles you care about; the rest keep Material's baseline purple defaults. That is useful but also a trap: forgetting a role (say tertiary) leaves a baseline purple leaking into your green app, usually in a component you did not test. For a shipping app, define all the roles, or generate them with the Theme Builder.
Now add the composable that applies everything. Append this to Theme.kt (keep the imports from above and add the new ones):
package com.example.naijasnacks.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun NaijaSnacksTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
(AppTypography and AppShapes come in the next phases. Until you write them, comment those two lines out.)
Let us read the when block carefully, because this is the heart of the file:
- Dynamic colour branch.
Build.VERSION_CODES.Sis Android 12 (API 31). From Android 12, the system can extract a palette from the user's wallpaper, anddynamicLightColorScheme(context)returns aColorSchemebuilt from it. Two people with different wallpapers see different, matching app colours. This is the "Material You" feature. - Dark branch. On older phones, or when dynamic colour is off, use your own dark scheme.
- Light branch. Otherwise your own light scheme.
isSystemInDarkTheme() reads the system setting (Settings → Display → Dark theme) and recomposes your UI automatically when the user flips it, with no restart needed for a Compose app.
Design choice: dynamic colour is wonderful for system-like apps, but if your brand colour matters (a bank, a school, a football club), set
dynamicColor = falseso your logo and UI never clash with a random wallpaper. Many apps offer a switch in settings. Whichever you choose, always keep the static fallback because many phones in the field run Android 11 or lower.
Testing both modes
In the preview, you can render both themes side by side. Add this to MainActivity.kt:
package com.example.naijasnacks
import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.naijasnacks.ui.theme.NaijaSnacksTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
NaijaSnacksTheme {
Surface(color = MaterialTheme.colorScheme.background) {
SnackCard("Chin chin", "₦500")
}
}
}
}
}
@Composable
fun SnackCard(name: String, price: String, modifier: Modifier = Modifier) {
Card(modifier.padding(16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(name, style = MaterialTheme.typography.titleLarge)
Text(price, style = MaterialTheme.typography.bodyMedium)
Button(onClick = { }) { Text("Add to cart") }
}
}
}
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun SnackCardPreview() {
NaijaSnacksTheme(dynamicColor = false) {
SnackCard("Chin chin", "₦500")
}
}
Two stacked @Preview annotations give you two preview panels. uiMode = Configuration.UI_MODE_NIGHT_YES forces night mode in the preview. We pass dynamicColor = false in previews so the preview shows your palette rather than the preview device's default wallpaper colours.
Compare light and dark. The card colour, text colour and button colour all flipped without a single if in SnackCard. That is the benefit of roles.
Read colours from the theme, never from constants, inside screens.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PromoBanner(text: String) {
Box(
Modifier
.background(MaterialTheme.colorScheme.tertiaryContainer)
.padding(12.dp)
) {
Text(text, color = MaterialTheme.colorScheme.onTertiaryContainer)
}
}
Notice the pair: background tertiaryContainer, text onTertiaryContainer. If you pair tertiaryContainer with onSurface, contrast may be too low in one of the modes, and you will not notice until someone uses dark theme.
contentColorFor and LocalContentColor
Many components pick text colour for you. Surface(color = ...) calls contentColorFor(color) which maps a background role to its matching "on" colour, and places it in LocalContentColor. Text and Icon default to LocalContentColor. That is why this works with no explicit text colour:
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun ErrorNotice(message: String) {
Surface(color = MaterialTheme.colorScheme.errorContainer) {
Text(message) // automatically onErrorContainer
}
}
Adding your own extra colours
Sometimes the design needs a colour Material does not define, for example a "success" green or a brand colour for a payment provider. Do not scatter hex codes. Add a small custom holder with a CompositionLocal, so it also follows dark mode:
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
@Immutable
data class ExtraColors(
val success: Color,
val onSuccess: Color,
val warning: Color
)
val LocalExtraColors = compositionLocalOf {
ExtraColors(
success = Color(0xFF2E7D32),
onSuccess = Color.White,
warning = Color(0xFFB26A00)
)
}
Provide it inside your theme function with CompositionLocalProvider(LocalExtraColors provides ...) { MaterialTheme(...) }, choosing different values for light and dark, and read it as LocalExtraColors.current.success. This is exactly how MaterialTheme itself works.
Typography is a set of named text styles. Material 3 defines fifteen, in five groups of three sizes:
| Group | Styles | Typical use |
|---|---|---|
| Display | displayLarge, displayMedium, displaySmall |
Very large, short text such as hero numbers |
| Headline | headlineLarge, headlineMedium, headlineSmall |
Screen titles |
| Title | titleLarge, titleMedium, titleSmall |
Section headings, app bars, card titles |
| Body | bodyLarge, bodyMedium, bodySmall |
Paragraphs and most text |
| Label | labelLarge, labelMedium, labelSmall |
Buttons, chips, captions |
Each style is a TextStyle with font family, size, weight, line height and letter spacing. Create ui/theme/Type.kt:
package com.example.naijasnacks.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val AppTypography = Typography(
titleLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
)
)
Any style you leave out keeps its Material default. FontFamily.SansSerif is the device's default sans-serif, which is good for a first pass. Notice the sizes are in sp, which scales with the user's font-size setting. Never use dp for text: a user who needs larger text for reading would be ignored (see lessons 46–47 on accessibility).
Changing every style at once
If you want the same font family for the whole app, you do not have to rewrite fifteen styles. Use copy:
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
fun Typography.withFontFamily(family: FontFamily): Typography = copy(
displayLarge = displayLarge.copy(fontFamily = family),
displayMedium = displayMedium.copy(fontFamily = family),
displaySmall = displaySmall.copy(fontFamily = family),
headlineLarge = headlineLarge.copy(fontFamily = family),
headlineMedium = headlineMedium.copy(fontFamily = family),
headlineSmall = headlineSmall.copy(fontFamily = family),
titleLarge = titleLarge.copy(fontFamily = family),
titleMedium = titleMedium.copy(fontFamily = family),
titleSmall = titleSmall.copy(fontFamily = family),
bodyLarge = bodyLarge.copy(fontFamily = family),
bodyMedium = bodyMedium.copy(fontFamily = family),
bodySmall = bodySmall.copy(fontFamily = family),
labelLarge = labelLarge.copy(fontFamily = family),
labelMedium = labelMedium.copy(fontFamily = family),
labelSmall = labelSmall.copy(fontFamily = family)
)
Then val AppTypography = Typography().withFontFamily(BrandFont).
This is the Styling text part of the programme. There are three ways to bring a font into your app, and the right choice depends on size and flexibility.
Option A: bundle a font file in res/font
- In Android Studio, switch the Project view to Android, right-click
res, choose New → Android Resource Directory, set Resource type tofont. - Copy font files (
.ttfor.otf) intores/font. File names must be lowercase letters, digits and underscores only (for examplebaloo_regular.ttf). - Reference them:
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.example.naijasnacks.R
val BrandFont = FontFamily(
Font(R.font.baloo_regular, FontWeight.Normal),
Font(R.font.baloo_bold, FontWeight.Bold)
)
Pros: works offline immediately, you control the exact file. Cons: each weight is a separate file, so your APK grows, and you must respect the font's licence (many Google Fonts are under the SIL Open Font License, which allows bundling, but always check).
Option B: downloadable fonts
The device downloads the font from Google Fonts through Google Play services the first time it is needed, and shares it with other apps. Your app stays small.
Dependency (look up the latest stable version of androidx.compose.ui:ui-text-google-fonts; it is versioned together with Compose):
[libraries]
androidx-compose-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" }
With the Compose BOM in your project the version is supplied for you. Then in your build file add implementation(libs.androidx.compose.ui.text.google.fonts).
In code you need a GoogleFont.Provider. The provider needs a certificate array resource, which Android Studio's tooling can create for you (in the XML font tooling, choose a Google Fonts font and it adds the resources), or you can copy the official certificate arrays from the documentation; do not type certificate hashes from memory.
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.Font as GoogleFontLoader
import androidx.compose.ui.text.googlefonts.GoogleFont
import com.example.naijasnacks.R
private val provider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs
)
private val bodyFont = GoogleFont("Nunito")
val NunitoFamily = FontFamily(
GoogleFontLoader(googleFont = bodyFont, fontProvider = provider, weight = FontWeight.Normal),
GoogleFontLoader(googleFont = bodyFont, fontProvider = provider, weight = FontWeight.Bold)
)
(Check the Use downloadable fonts page in the official Compose docs for the current import names and the certificate array, because this API is updated from time to time.)
Important behaviour: the font may not be available on the first launch, for instance with no data connection or without Google Play services. Compose shows the fallback font until the download finishes. Always add a fallback to your FontFamily and test in airplane mode:
val SafeFamily = FontFamily(
GoogleFontLoader(googleFont = bodyFont, fontProvider = provider),
androidx.compose.ui.text.font.Font(R.font.nunito_regular) // bundled backup
)
Pros: tiny APK, shared across apps. Cons: needs Google Play services and a connection once; not ideal for devices without them.
Option C: variable fonts
A normal font has fixed weights (Regular, Bold). A variable font is one file that contains an entire axis of variation, for example weight from 100 to 900, width, or italic slant. You can ask for weight 450 and get exactly that. In Compose, from API 26, you describe the axis settings with FontVariation:
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import com.example.naijasnacks.R
@OptIn(ExperimentalTextApi::class)
val FlexFamily = FontFamily(
Font(
resId = R.font.roboto_flex_variable,
weight = FontWeight.Normal,
variationSettings = FontVariation.Settings(
FontVariation.weight(400),
FontVariation.width(100f)
)
),
Font(
resId = R.font.roboto_flex_variable,
weight = FontWeight.Bold,
variationSettings = FontVariation.Settings(
FontVariation.weight(700),
FontVariation.width(100f)
)
)
)
You use the same file twice with different settings. The opt-in annotation reflects that parts of this API were experimental at the time of writing; check the current Fonts in Compose documentation, because annotations are removed as APIs stabilise. Variable fonts are a good fit when you want several weights from one file, so the APK stays small, but only some fonts ship in variable form, and axis support differs between fonts.
Which should you choose?
| Need | Choose |
|---|---|
| Guaranteed to work offline, one or two weights | Bundled font |
| Smallest APK, wide choice of Google Fonts | Downloadable font with a bundled fallback |
| Many weights from one file | Variable font |
TextStyle and the Text parameters
Text has many parameters; the ones you will use daily:
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
@Composable
fun StyledTextDemo() {
Text(
text = "Jollof rice with fried plantain and moin moin, served hot every afternoon.",
style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.2.sp,
textAlign = TextAlign.Start,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Parameters passed directly (fontStyle, fontWeight) override the same property inside style. So style is your base and the parameters are tweaks. With maxLines = 2 and TextOverflow.Ellipsis, long text is cut with "…". Note that without a maxLines, text wraps without limit.
Several styles in one text: AnnotatedString
If you want one word bold or a different colour in a sentence, you cannot use three Text composables, because they would not wrap as a paragraph. Use AnnotatedString, built with buildAnnotatedString:
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
@Composable
fun PriceLine(oldPrice: String, newPrice: String) {
val text = buildAnnotatedString {
append("Was ")
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
append(oldPrice)
}
append(" now ")
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
) {
append(newPrice)
}
}
Text(text)
}
SpanStyle changes character-level properties (colour, weight, decoration, font). ParagraphStyle changes paragraph-level ones (alignment, line height, indentation) and applies to whole paragraphs.
Clickable text with links
Modern Compose has a LinkAnnotation for links inside text, which handles clicks and accessibility for you:
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
@Composable
fun TermsLine() {
val linkStyle = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
)
)
Text(
buildAnnotatedString {
append("By continuing you accept our ")
withLink(LinkAnnotation.Url("https://developer.android.com", linkStyle)) {
append("terms")
}
append(".")
}
)
}
This API replaced the older ClickableText approach, which is not recommended any more. Check the Style text documentation if names have shifted.
Selectable text, brush and shadows
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@Composable
fun FancyTitle() {
Text(
"Naija Snacks",
style = TextStyle(
fontSize = 36.sp,
fontWeight = FontWeight.ExtraBold,
brush = Brush.linearGradient(listOf(Color(0xFF1B6B3A), Color(0xFFFFB86B))),
shadow = Shadow(Color.Gray, Offset(2f, 4f), blurRadius = 4f)
)
)
}
@Composable
fun CopyableAddress() {
SelectionContainer {
Text("12 Dugbe Road, Ibadan")
}
}
By default text is not selectable. Wrap it in SelectionContainer to let the user long-press and copy. Gradient text looks nice but check contrast (lesson 47).
Shapes defines five corner sizes in Material 3: extraSmall, small, medium, large, extraLarge. Components use them: buttons are fully rounded, cards use medium, dialogs use extraLarge, and so on.
package com.example.naijasnacks.ui.theme
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val AppShapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp),
large = CutCornerShape(topStart = 24.dp, bottomEnd = 24.dp),
extraLarge = RoundedCornerShape(32.dp)
)
Apply a shape to your own composable with Modifier.clip(MaterialTheme.shapes.medium) or pass shape = MaterialTheme.shapes.large to a component such as Card. Remember RoundedCornerShape(50) (a percentage) gives a pill.
On recent Android versions apps draw edge to edge, behind the status bar and the navigation bar. Calling enableEdgeToEdge() before setContent (as we did) makes the bars transparent and chooses light or dark icons to match the system theme. Then you must keep your content out from under the bars, which Scaffold does for you through its innerPadding parameter:
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun SnackScreen() {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Text("Menu", modifier = Modifier.padding(innerPadding))
}
}
If you ignore innerPadding, your content hides under the status bar. If the template warns that innerPadding is unused, that is the reason.
Overriding the theme for one section
MaterialTheme can be nested. A theme inside a theme overrides only what you set:
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun SaleBanner() {
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
primary = MaterialTheme.colorScheme.error,
onPrimary = MaterialTheme.colorScheme.onError
)
) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text("SALE: 20% off today")
}
}
}
Inside the inner block, primary is now the error colour. Use this sparingly (for example for a "danger zone" settings section).
Exercise 1: Theme a school app
Create a theme for a school app called Ibadan Scholars. Use a navy blue as the primary colour (light and dark variants), amber as secondary, dynamicColor = false by default, and titleLarge in bold 24 sp. Show a card that uses primaryContainer/onPrimaryContainer. Preview it in light and dark mode.
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val Light = lightColorScheme(
primary = Color(0xFF1F3A7A),
onPrimary = Color.White,
primaryContainer = Color(0xFFDCE2FF),
onPrimaryContainer = Color(0xFF001A41),
secondary = Color(0xFF8A5A00),
onSecondary = Color.White
)
private val Dark = darkColorScheme(
primary = Color(0xFFB2C5FF),
onPrimary = Color(0xFF0A2A6B),
primaryContainer = Color(0xFF29458E),
onPrimaryContainer = Color(0xFFDCE2FF),
secondary = Color(0xFFFFBA4C),
onSecondary = Color(0xFF462C00)
)
private val ScholarTypography = Typography(
titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp)
)
@Composable
fun ScholarsTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) Dark else Light,
typography = ScholarTypography,
content = content
)
}
The card:
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun NoticeCard() {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Text("Mid-term break begins Friday", style = MaterialTheme.typography.titleLarge)
}
}
Typography(titleLarge = ...) keeps the 14 other styles as defaults.
Exercise 2: Rich price text
Write a composable Receipt(item, qty, total) that shows "2 × Chin chin = ₦1,000" where the total is bold and coloured with primary, and the multiplication sign and "=" use onSurfaceVariant. Use one Text.
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
@Composable
fun Receipt(item: String, qty: Int, total: String) {
val muted = MaterialTheme.colorScheme.onSurfaceVariant
val strong = MaterialTheme.colorScheme.primary
Text(
buildAnnotatedString {
append("$qty ")
withStyle(SpanStyle(color = muted)) { append("× ") }
append(item)
withStyle(SpanStyle(color = muted)) { append(" = ") }
withStyle(SpanStyle(color = strong, fontWeight = FontWeight.Bold)) { append(total) }
}
)
}
One Text means one paragraph that wraps correctly on narrow screens. Three separate Text composables in a Row would not.
Exercise 3: A theme switch
Add a Switch to your app that lets the user choose between System, Light and Dark theme, and apply the choice to the theme function. Keep the choice in a rememberSaveable for now (lesson 35 shows how to store it in DataStore).
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
enum class ThemeChoice { System, Light, Dark }
@Composable
fun ThemedApp() {
var choice by rememberSaveable { mutableStateOf(ThemeChoice.System) }
val dark = when (choice) {
ThemeChoice.System -> isSystemInDarkTheme()
ThemeChoice.Light -> false
ThemeChoice.Dark -> true
}
NaijaSnacksTheme(darkTheme = dark) {
Column {
ThemeChoice.entries.forEach { option ->
Row {
RadioButton(selected = choice == option, onClick = { choice = option })
Text(option.name)
}
}
}
}
}
A "System/Light/Dark" choice is better as radio buttons than as a single Switch, because there are three states. (ThemeChoice.entries needs Kotlin 1.9 or later; use values() on older versions.)
Mistake #1: Hard-coding colours in screens. Color(0xFF1B6B3A) inside a screen will not change in dark mode. Fix: read MaterialTheme.colorScheme.primary.
Mistake #2: Wrong pair. Using primaryContainer with onPrimary text. Symptom: poor contrast in one mode. Fix: use matching pairs, or let Surface choose via contentColorFor.
Mistake #3: Leaving some roles at default. Symptom: a stray baseline purple in a date picker or a chip. Fix: define all the roles, or generate the scheme with Material Theme Builder.
Mistake #4: Dynamic colour with no fallback. Symptom: a crash on Android 11 and lower from calling dynamicLightColorScheme. Fix: guard with Build.VERSION.SDK_INT >= Build.VERSION_CODES.S.
Mistake #5: Using dp for text sizes. Symptom: text ignores the user's font-size setting. Fix: sp.
Mistake #6: Relying on a downloadable font being there. Symptom: the wrong font on first launch or without Play services. Fix: give a fallback font in the family.
Mistake #7: Three Texts in a Row to mix styles. Symptom: odd wrapping. Fix: buildAnnotatedString.
Mistake #8: Previews that only show light mode. Symptom: dark mode bugs ship. Fix: stack a uiMode night preview.
Mistake #9: Ignoring innerPadding from Scaffold. Symptom: content under the status bar or navigation bar. Fix: apply the padding.
Mistake #10: Forgetting font licences. Symptom: a legal problem later. Fix: check the licence of any font you bundle. Open-licensed families such as those on Google Fonts are a safe starting point.
Question 1. What are colour roles and why does Material use them instead of fixed colours?
Roles are named purposes (such as primary, onPrimary, surface) that the theme maps to actual colours. Screens refer to the purpose, so changing the theme, or switching to dark mode or dynamic colour, changes every screen automatically while keeping text readable through paired "on" colours.
Question 2. How do you enable dynamic colour safely?
Check Build.VERSION.SDK_INT >= Build.VERSION_CODES.S (Android 12), then use dynamicLightColorScheme(context) or dynamicDarkColorScheme(context). Otherwise fall back to your own light and dark schemes.
Question 3. Which three objects does MaterialTheme take, and how do you read them?
colorScheme, typography and shapes. Read them with MaterialTheme.colorScheme, MaterialTheme.typography and MaterialTheme.shapes anywhere inside the theme.
Question 4. What is the difference between SpanStyle and ParagraphStyle?
SpanStyle styles characters within a range (colour, weight, decoration). ParagraphStyle styles whole paragraphs (alignment, line height, indent).
Question 5. When would you choose a variable font over a bundled static font?
When you need several weights, widths or slants of one family and want a single smaller file instead of one file per weight. You then use FontVariation settings on the same resource.
Go deeper: official resources
- Jetpack Compose for Android developers, Pathway 2: Layouts, theming and animation
- Jetpack Compose theming codelab
- Jetpack Compose course page
- Material Design 3 in Compose
Project goal: Turn the Naija Snacks demo into a polished, themed menu app that looks right in light, dark and (on Android 12+) wallpaper-driven colour.
Requirements:
- A theme with a full light and dark
ColorSchemeof your own colours, adynamicColorparameter, and a static fallback. - A downloaded or bundled brand font used for all text, with a safe fallback.
- Custom
Shapes: cards usemedium, price tags use a pill shape. - A menu of at least eight snacks in a
LazyColumn, each in aCard, with the name intitleLargeand the price shown throughAnnotatedString(naira sign in a different colour). - A theme chooser (System, Light, Dark) at the top, plus a dynamic colour switch that is hidden on Android 11 and lower.
- Light and dark previews.
Stretch goal: add an ExtraColors composition local with a "spicy" colour that differs in dark mode, and use it for a chilli badge.
package com.example.naijasnacks
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.example.naijasnacks.ui.theme.NaijaSnacksTheme
enum class ThemeChoice { System, Light, Dark }
data class Snack(val name: String, val naira: Int)
private val menu = listOf(
Snack("Chin chin", 500), Snack("Puff puff", 300), Snack("Suya", 1500),
Snack("Akara", 400), Snack("Gala", 350), Snack("Plantain chips", 600),
Snack("Meat pie", 700), Snack("Zobo", 250)
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { SnacksApp() }
}
}
@Composable
fun SnacksApp() {
var choice by rememberSaveable { mutableStateOf(ThemeChoice.System) }
var dynamic by rememberSaveable { mutableStateOf(false) }
val dark = when (choice) {
ThemeChoice.System -> isSystemInDarkTheme()
ThemeChoice.Light -> false
ThemeChoice.Dark -> true
}
NaijaSnacksTheme(darkTheme = dark, dynamicColor = dynamic) {
Scaffold { innerPadding ->
LazyColumn(
contentPadding = PaddingValues(
start = 16.dp, end = 16.dp,
top = innerPadding.calculateTopPadding() + 8.dp,
bottom = innerPadding.calculateBottomPadding() + 8.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
ThemeChoice.entries.forEach { option ->
RadioButton(selected = choice == option, onClick = { choice = option })
Text(option.name)
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = dynamic, onCheckedChange = { dynamic = it })
Text(" Use wallpaper colours")
}
}
}
items(menu, key = { it.name }) { snack -> SnackRow(snack) }
}
}
}
}
@Composable
fun SnackRow(snack: Snack) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(snack.name, style = MaterialTheme.typography.titleLarge)
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.secondary)) { append("₦") }
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("%,d".format(snack.naira)) }
},
style = MaterialTheme.typography.bodyLarge
)
}
}
}
What you should see: a list of eight cards in green and cream; switching to Dark turns the screen dark with light-green accents. On Android 12 or later, the "Use wallpaper colours" switch appears, and turning it on changes every colour to match your wallpaper. The pill-shaped price tag from requirement 3 is a Surface(shape = RoundedCornerShape(50)) around the price Text.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 40 project”.
On github.com click New repository, name it android-lesson-40-material-design-3-theming-dynamic-color-an, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 40 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: What Is a Design System?
- Step-by-Step Build 1: Creating a Colour Scheme
- Step-by-Step Build 2: The Theme Composable, Dark Theme and Dynamic Colour
- Step-by-Step Build 3: Using Colours Correctly
- Step-by-Step Build 4: Typography
- Step-by-Step Build 5: Fonts, Downloadable Fonts and Variable Fonts
- Step-by-Step Build 6: Styling Text in Depth
- Step-by-Step Build 7: Shapes
- Step-by-Step Build 8: System Bars and Edge-to-Edge
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check