Android Typography and Color: Getting Material Design Right Without the Guesswork

AppBooster Team · · 12 min read
Colorful typography specimens and color palette swatches on a design table

Your app’s logic is airtight. The architecture is clean. Then a designer opens it for the first time and says — politely, painfully — “the typography feels off.”

They’re right. And the problem isn’t your taste. It’s that Material Design 3’s type system has 21 distinct styles organized across 5 semantic roles, and most developers pick sizes by feel. Meanwhile, research consistently shows that typography accounts for roughly 70% of perceived UI quality on mobile — before users consciously notice anything else.

This post cuts through the guesswork. You’ll get the exact type scale, the color token system that makes dark mode automatic, and the Compose code to wire it all together.


Why the Type Scale Exists (and Why Ignoring It Costs You)

Material 3 didn’t invent its type scale arbitrarily. Every size, weight, and line height maps to a specific reading context. The 5 roles are:

RolePurposeExample usage
DisplayHero moments, large decorative textOnboarding splash, app logo
HeadlinePrimary content headersScreen titles, card headers
TitleSecondary structureList item primary text, dialog titles
BodyReading — the majority of your contentArticles, descriptions, settings copy
LabelSmall, functional textButtons, captions, form labels

Each role has three sizes (Large, Medium, Small), giving you 15 named styles. Add the 6 display sizes and you land at 21 total. That precision isn’t bureaucracy — it’s what lets you build a visual hierarchy users parse instantly without conscious effort.

The practical mistake: developers collapse all of this into 3-4 hardcoded fontSize values. The result is content that looks technically correct but reads as flat — no hierarchy, no breathing room, no sense of what matters.

Material Design type scale showing Display, Headline, Title, Body, and Label roles with size specimens


Setting Up the Full Type Scale in Compose

Compose’s MaterialTheme takes a Typography object. Here’s a production-ready configuration that maps Material 3’s scale correctly:

// ui/theme/Type.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
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.unit.sp

val RobotoFamily = FontFamily(
    Font(R.font.roboto_regular, FontWeight.Normal),
    Font(R.font.roboto_medium, FontWeight.Medium),
    Font(R.font.roboto_bold, FontWeight.Bold)
)

val AppTypography = Typography(
    // Display — decorative, large impact
    displayLarge = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 57.sp,
        lineHeight = 64.sp,
        letterSpacing = (-0.25).sp
    ),
    displayMedium = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 45.sp,
        lineHeight = 52.sp,
        letterSpacing = 0.sp
    ),
    displaySmall = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 36.sp,
        lineHeight = 44.sp,
        letterSpacing = 0.sp
    ),
    // Headline — screen-level structure
    headlineLarge = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 32.sp,
        lineHeight = 40.sp,
        letterSpacing = 0.sp
    ),
    headlineMedium = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 28.sp,
        lineHeight = 36.sp,
        letterSpacing = 0.sp
    ),
    headlineSmall = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.SemiBold,
        fontSize = 24.sp,
        lineHeight = 32.sp,
        letterSpacing = 0.sp
    ),
    // Title — component-level labels
    titleLarge = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 22.sp,
        lineHeight = 28.sp,
        letterSpacing = 0.sp
    ),
    titleMedium = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Medium,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.15.sp
    ),
    titleSmall = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Medium,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.1.sp
    ),
    // Body — reading content (minimum 16sp)
    bodyLarge = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,   // 1.5x — center of the 1.4–1.6x ideal range
        letterSpacing = 0.5.sp
    ),
    bodyMedium = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.25.sp
    ),
    bodySmall = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 12.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.4.sp
    ),
    // Label — functional, small-scale text
    labelLarge = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Medium,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.1.sp
    ),
    labelMedium = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Medium,
        fontSize = 12.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    ),
    labelSmall = TextStyle(
        fontFamily = RobotoFamily,
        fontWeight = FontWeight.Medium,
        fontSize = 11.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    )
)

Two things worth noting in that code. First, bodyLarge is 16sp — the minimum for comfortable reading. If you’re pushing body text below that, you’re asking users to squint. Long-form content (articles, changelogs, release notes) should go to 18sp. Second, line heights follow the 1.4–1.6× multiplier rule: bodyLarge at 16sp gets 24sp line height (1.5×). That ratio is where readability research consistently lands.


Roboto and Variable Fonts: What Actually Matters

Roboto is the default Android typeface and the safe bet for system consistency. But if you’re using Roboto Flex — the variable font version — you get continuous adjustment across weight, width, and optical size without shipping multiple font files.

// Variable font with custom axes
val RobotoFlexFamily = FontFamily(
    Font(
        resId = R.font.roboto_flex,
        weight = FontWeight(100..900),  // full variable weight range
    )
)

// Use weight as a continuous value
TextStyle(
    fontFamily = RobotoFlexFamily,
    fontVariationSettings = FontVariation.Settings(
        FontVariation.weight(450),      // between Regular (400) and Medium (500)
        FontVariation.width(100f),      // normal width
        FontVariation.opticalSize(16f)  // optimized for 16sp rendering
    )
)

The optical size axis (opsz) automatically adjusts stroke contrast for the rendered size — wider strokes at small sizes for legibility, more refined strokes at display sizes. This is especially visible in dark mode, where thin strokes on light text can disappear at small sizes. Roboto Flex handles this for you when opsz tracks your fontSize.


Material You: Where Your Color System Lives

Here’s the architecture most developers misunderstand. Material You doesn’t give you a color. It gives you a 65-color system — 5 tonal palettes × 13 shades each — derived from a single seed color via the CAM16 perceptual color model.

Those 65 colors map to named color roles: primary, onPrimary, primaryContainer, onPrimaryContainer, and so on across primary, secondary, tertiary, error, neutral, and neutral-variant families.

Dynamic color system showing tonal palettes derived from wallpaper colors

The system handles light and dark modes by selecting different shade indexes from the same palettes. Your job is to reference roles — never raw hex values.

// ui/theme/Color.kt
// Define your seed-derived palette (or use dynamic colors on Android 12+)

val md_theme_light_primary = Color(0xFF6750A4)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFEADDFF)
val md_theme_light_onPrimaryContainer = Color(0xFF21005D)
val md_theme_light_secondary = Color(0xFF625B71)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFE8DEF8)
val md_theme_light_onSecondaryContainer = Color(0xFF1D192B)
val md_theme_light_background = Color(0xFFFFFBFE)
val md_theme_light_onBackground = Color(0xFF1C1B1F)
val md_theme_light_surface = Color(0xFFFFFBFE)
val md_theme_light_onSurface = Color(0xFF1C1B1F)

// Dark variants — same palette, different shade indexes
val md_theme_dark_primary = Color(0xFFD0BCFF)
val md_theme_dark_onPrimary = Color(0xFF381E72)
val md_theme_dark_primaryContainer = Color(0xFF4F378B)
val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF)
val md_theme_dark_secondary = Color(0xFFCCC2DC)
val md_theme_dark_onSecondary = Color(0xFF332D41)
val md_theme_dark_secondaryContainer = Color(0xFF4A4458)
val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8)
val md_theme_dark_background = Color(0xFF1C1B1F)
val md_theme_dark_onBackground = Color(0xFFE6E1E5)
val md_theme_dark_surface = Color(0xFF1C1B1F)
val md_theme_dark_onSurface = Color(0xFFE6E1E5)

Wire these into a ColorScheme and pass it to MaterialTheme:

// ui/theme/Theme.kt
@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,  // Material You wallpaper extraction
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        // Dynamic color available on Android 12+ (API 31)
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        darkTheme -> darkColorScheme(
            primary = md_theme_dark_primary,
            onPrimary = md_theme_dark_onPrimary,
            primaryContainer = md_theme_dark_primaryContainer,
            onPrimaryContainer = md_theme_dark_onPrimaryContainer,
            secondary = md_theme_dark_secondary,
            onSecondary = md_theme_dark_onSecondary,
            secondaryContainer = md_theme_dark_secondaryContainer,
            onSecondaryContainer = md_theme_dark_onSecondaryContainer,
            background = md_theme_dark_background,
            onBackground = md_theme_dark_onBackground,
            surface = md_theme_dark_surface,
            onSurface = md_theme_dark_onSurface,
        )
        else -> lightColorScheme(
            primary = md_theme_light_primary,
            onPrimary = md_theme_light_onPrimary,
            primaryContainer = md_theme_light_primaryContainer,
            onPrimaryContainer = md_theme_light_onPrimaryContainer,
            secondary = md_theme_light_secondary,
            onSecondary = md_theme_light_onSecondary,
            secondaryContainer = md_theme_light_secondaryContainer,
            onSecondaryContainer = md_theme_light_onSecondaryContainer,
            background = md_theme_light_background,
            onBackground = md_theme_light_onBackground,
            surface = md_theme_light_surface,
            onSurface = md_theme_light_onSurface,
        )
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        content = content
    )
}

With this setup, every component that uses MaterialTheme.colorScheme.primary automatically gets the right value for light, dark, and dynamic color — no conditional logic scattered through your composables.


The Dark Background Rule Everyone Gets Wrong

Pure black (#000000) feels harsh in dark mode. The contrast between pure black and white text is so extreme that it creates a halation effect — text appears to bleed at the edges, especially on OLED displays with aggressive brightness.

Material Design specifies #121212 as the baseline dark surface. The difference is subtle in a color picker and meaningful at 2am when your user is reading in bed.

// Wrong
val DarkBackground = Color(0xFF000000)

// Correct — Material 3 dark surface baseline
val DarkBackground = Color(0xFF121212)

// Even better: let the color system handle it
// md_theme_dark_background = Color(0xFF1C1B1F) — the M3 default

The elevation system in dark mode also matters here. Material 3 uses tonal elevation — surfaces at higher elevation get a subtle primary color overlay — rather than drop shadows. This means Card at elevation 2dp will automatically appear slightly lighter than the background surface, reinforcing hierarchy without any extra code.

Dark mode UI with subtle tonal elevation distinguishing surface levels


WCAG Contrast: The Numbers You Need to Hit

Accessibility contrast isn’t optional if you want your app visible on the Play Store to enterprise clients, and it’s table stakes for user experience regardless. The thresholds:

Element typeMinimum ratioEnhanced (AAA)
Body text (< 18sp or < 14sp bold)4.5:17:1
Large text (≥ 18sp or ≥ 14sp bold)3:14.5:1
UI components and icons3:1

Check your theme’s ratios at design time, not after you’ve shipped:

// Utility to verify contrast ratio programmatically during development
fun contrastRatio(foreground: Color, background: Color): Double {
    fun relativeLuminance(color: Color): Double {
        fun channel(c: Float): Double {
            val linear = c.toDouble()
            return if (linear <= 0.03928) linear / 12.92
            else Math.pow((linear + 0.055) / 1.055, 2.4)
        }
        return 0.2126 * channel(color.red) +
               0.7152 * channel(color.green) +
               0.0722 * channel(color.blue)
    }

    val l1 = relativeLuminance(foreground)
    val l2 = relativeLuminance(background)
    val lighter = maxOf(l1, l2)
    val darker = minOf(l1, l2)
    return (lighter + 0.05) / (darker + 0.05)
}

// In debug builds, assert your token combinations pass WCAG AA
assert(contrastRatio(
    foreground = md_theme_dark_onSurface,
    background = md_theme_dark_surface
) >= 4.5) { "onSurface/surface fails WCAG AA in dark mode" }

The Material 3 default palette passes WCAG AA out of the box. The risk is customization — particularly when teams pick brand colors without checking the resulting onX contrast against the generated X container colors.


The Three Patterns That Break Everything

After the theory, the practical failure modes worth knowing:

1. Hardcoded hex values in composables

// This breaks dark mode and dynamic color entirely
Text(
    text = "Settings",
    color = Color(0xFF6750A4)  // hardcoded primary
)

// Use semantic tokens
Text(
    text = "Settings",
    color = MaterialTheme.colorScheme.primary
)

2. Font weight as aesthetic choice in dark mode

Thin fonts (weight 100–300) look refined on white backgrounds. On dark backgrounds, they become unreadable at body sizes. Set a minimum weight for dark mode:

val bodyWeight = if (isSystemInDarkTheme()) FontWeight.Normal else FontWeight.Light

3. Color token mismatch

primaryContainer is meant to hold content — use onPrimaryContainer on top of it, not onPrimary. The on prefix must match the surface you’re placing it on. Mixing these pairs breaks contrast guarantees the system was designed to provide.


Connecting This to Real-World App Quality

Typography and color decisions compound. An app that uses the correct type scale, semantic color tokens, and WCAG-compliant contrast doesn’t just look better — it performs better in user testing, gets higher ratings, and spends less time in review cycles. The systems exist so you don’t have to design from scratch.

If you’re building or optimizing an Android app, AppBooster tracks the metrics that reflect these quality signals — rating velocity, review sentiment, and store visibility — so you can see when design changes actually move the needle with users.

Developer reviewing app UI components on a mobile device with design tokens visible


Putting It Together

The complete theme wires AppTypography and your ColorScheme into one MaterialTheme call at your app root:

// MainActivity.kt (or NavHost root)
setContent {
    AppTheme {
        // All composables below automatically use your type scale and color system
        AppNavigation()
    }
}

Every Text, Button, Card, and TopAppBar in the Material 3 library reads from this theme. You define the system once; the components consume it consistently.

The only ongoing discipline: when you reach for a color or text size, use a token name, not a raw value. MaterialTheme.colorScheme.surfaceVariant, not Color(0xFFE7E0EC). MaterialTheme.typography.bodyLarge, not TextStyle(fontSize = 16.sp).

That one habit — semantic tokens over raw values — is what separates a theme that works from one that breaks the moment a user turns on dark mode or changes their wallpaper.


Checklist: Material 3 Typography and Color Audit

Before shipping, run through this:

  • All Text composables use MaterialTheme.typography.* styles, not inline TextStyle
  • Body text is minimum 16sp; long-form content is 18sp
  • Line heights follow 1.4–1.6× the font size
  • All colors reference MaterialTheme.colorScheme.* tokens
  • Dark surface background is #121212 or the M3 default (#1C1B1F), never #000000
  • onX tokens match their paired surface (onPrimary on primary, onSurface on surface)
  • WCAG AA contrast verified for all text/background combinations (4.5:1 body, 3:1 large text)
  • Font weight in dark mode is Normal minimum for body text
  • Dynamic color enabled on Android 12+ with static fallback for older devices
  • No hardcoded hex values in composable files

Material Design’s type and color systems have a learning curve. Once internalized, they eliminate entire categories of design decisions — and with them, the revision cycles that come from getting those decisions wrong.

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles