Android – Building a Design System with Material 3

September 1, 20269 min readUpdated 10/11/2026

Nine lessons in, every screen has used the same red, the same rounded cards, the same 16dp gaps and the same button. None of those values appear in a screen's code. They come from a design system — a small set of files that every screen draws from, so that changing the brand is a change in one place rather than a search across forty.

Material 3, Android's design language, provides much of a design system out of the box. This lesson is how to build on it rather than around it: where Material's colour scheme fits, where it runs out, and how to fill the gap without fighting the framework.

Three layers

The structure is the same one the iOS version of this app uses, and most mature design systems converge on it:

  1. Tokens — raw values with no meaning: this red, this grey, 16dp.
  2. Semantic roles — what each value is for: the primary colour, the text colour, the colour of a successful order.
  3. Components — the button, the card, the badge, built only from semantic roles.

The point of the middle layer is that features never name a token. A screen asks for "the primary colour", not "red", and the day the brand red changes — or a dark palette arrives — the change happens in the semantic layer and nowhere else.

Layer 1: tokens

object Palette {
    // `Red` and `Black` are lifted straight from the web tokens so all five apps match.
    val Red = Color(0xFFD8102A)
    val RedDark = Color(0xFFAB0D21)
    val RedSoft = Color(0xFFFDEAEC)
    val Black = Color(0xFF231F20)
    val Cream = Color(0xFFFFF8F0)
    val White = Color(0xFFFFFFFF)

    val Grey900 = Color(0xFF231F20)
    val Grey700 = Color(0xFF4A4746)
    val Grey600 = Color(0xFF6C6A68)
    val Grey400 = Color(0xFFA9A5A1)
    val Grey300 = Color(0xFFD5D1CD)
    val Grey200 = Color(0xFFECEAE7)
    val Grey100 = Color(0xFFF5F3F0)
    // …

The red and black are copied from the web app's token file, so all five apps against this backend match. Colours are written as 0xAARRGGBB — the leading FF is full opacity.

Spacing and shape are tokens too:

object Spacing {
    val xs = 4.dp
    val sm = 8.dp
    val md = 12.dp
    val lg = 16.dp
    val xl = 24.dp
    val xxl = 32.dp
    val xxxl = 48.dp
}

A 4dp grid. Every gap in the app is one of these, never a literal 13.dp. That sounds like bureaucracy until you look at a screen built without it: thirteen slightly different paddings, none of them chosen, all of them visible as a vague sense that the layout is off.

val MinTouchTarget = 48.dp

The minimum touch target is a token rather than a magic number in each component, because it is a rule — 48dp, from Material's accessibility guidance — that every tappable thing must honour.

Layer 2: semantic roles, and where Material fits

Material 3 already has a semantic layer: the ColorScheme, with roles like primary, onPrimary (text drawn on primary), surface, onSurfaceVariant, outline and error. Every Material component reads its colours from those roles. Fill them with your brand and every stock button, text field, chip and navigation bar picks it up with no further work:

val scheme = lightColorScheme(
    primary = colors.primary,
    onPrimary = colors.onPrimary,
    primaryContainer = colors.primarySoft,
    onPrimaryContainer = colors.primaryDark,
    secondary = colors.surfaceInverse,
    onSecondary = colors.onSurfaceInverse,
    background = colors.background,
    onBackground = colors.text,
    surface = colors.surface,
    onSurface = colors.text,
    surfaceVariant = colors.surfaceAlt,
    onSurfaceVariant = colors.textMuted,
    surfaceContainerLow = colors.background,
    outline = colors.border,
    outlineVariant = colors.borderSubtle,
    error = colors.danger,
)

This is the part people skip, and then spend weeks overriding colours component by component. Map the scheme first; override only what the scheme cannot express.

Where Material runs out

The ColorScheme has no role for "success" or "warning". An order badge needs both — green for completed, amber for awaiting payment. Inventing meanings for unused Material roles (tertiary as success?) works until someone else reads the code. Instead the app defines its own semantic set:

@Immutable
data class PizzaColors(
    val primary: Color = Palette.Red,
    val primaryDark: Color = Palette.RedDark,
    val primarySoft: Color = Palette.RedSoft,
    // …

…and makes it available the same way Material makes its own:

private val LocalPizzaColors = staticCompositionLocalOf { PizzaColors() }
object PizzaTheme {
    val colors: PizzaColors
        @Composable @ReadOnlyComposable
        get() = LocalPizzaColors.current
}

A CompositionLocal is a value provided by an ancestor and readable by any descendant, without being passed through every function in between. MaterialTheme.colorScheme is itself a CompositionLocal read through an object; PizzaTheme.colors copies the pattern exactly, so a screen reads PizzaTheme.colors.success the way it reads MaterialTheme.colorScheme.primary.

Two details. staticCompositionLocalOf rather than compositionLocalOf: the static variant does not track who read it, so changing it recomposes everything beneath the provider — expensive if it changed often, cheaper for a theme that changes approximately never. And @ReadOnlyComposable on the getter tells the compiler it only reads, which lets it skip some bookkeeping on every call.

CompositionLocals are easy to overuse. They are right for ambient, app-wide values — theme, locale, density — and wrong for a screen's data, which should be passed explicitly so a reader can see where it comes from.

The theme composable

fun PizzaTheme(content: @Composable () -> Unit) {
    val colors = PizzaColors()
    val scheme = lightColorScheme(
        primary = colors.primary,
        onPrimary = colors.onPrimary,
        primaryContainer = colors.primarySoft,
        onPrimaryContainer = colors.primaryDark,
        secondary = colors.surfaceInverse,
        onSecondary = colors.onSurfaceInverse,
        background = colors.background,
        onBackground = colors.text,
        surface = colors.surface,
        onSurface = colors.text,
        surfaceVariant = colors.surfaceAlt,
        onSurfaceVariant = colors.textMuted,
        surfaceContainerLow = colors.background,
        outline = colors.border,
        outlineVariant = colors.borderSubtle,
        error = colors.danger,
    )

    CompositionLocalProvider(LocalPizzaColors provides colors) {
        MaterialTheme(colorScheme = scheme, typography = PizzaTypography, content = content)
    }
}
    // …

One composable provides both layers. MainActivity wraps the whole app in it, and every @Preview wraps its content in it too — which is why a preview looks exactly like the app. Typography is mapped the same way as colour: Material's type scale (titleMedium, bodySmall) filled with the app's sizes and weights, in sp so the customer's font-size setting scales all of it.

Dynamic colour and dark mode

Android 12 can derive a whole colour scheme from the user's wallpaper — dynamicLightColorScheme(context). It is lovely for a utility app and wrong for a brand: a pizza brand is red whatever the wallpaper is, so this app does not use it.

The app is also light-only, matching the three web frontends and the iOS app. That is a scope decision, and the structure makes undoing it cheap: a second PizzaColors and a second lightColorScheme mapping (a darkColorScheme), chosen with isSystemInDarkTheme() inside PizzaTheme. No screen changes, because no screen names a colour.

Two themes: the window and Compose

There is a second, older theme that Compose does not replace: the XML theme the window uses before Compose draws its first frame.

<style name="Theme.Pizza" parent="android:Theme.Material.Light.NoActionBar">
    <item name="android:windowBackground">@color/pizza_background</item>
</style>

Its only job is the window background. Without it, the app flashes white between the splash screen and the first Compose frame — a visible stutter on every launch. Everything else the customer sees is styled by PizzaTheme. (The same file also defines the splash screen theme; that is the lifecycle lesson.)

Layer 3: components

A component is built from semantic roles only, and encodes behaviour that every use of it would otherwise have to remember. The button is the clearest example:

fun PizzaButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    style: PizzaButtonStyle = PizzaButtonStyle.PRIMARY,
    enabled: Boolean = true,
    isLoading: Boolean = false,
    fullWidth: Boolean = true,
) {
    val sizing = modifier
        .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier)
        .heightIn(min = MinTouchTarget)
        // TalkBack hears "Place order, busy" rather than an unlabelled spinner.
        .semantics { if (isLoading) stateDescription = "Busy" }
    val shape = RoundedCornerShape(Radius.md)
    val isEnabled = enabled && !isLoading
    val content: @Composable () -> Unit = { ButtonContent(text, isLoading) }
        // …

Three decisions are built in:

  • A style, not a colour. Callers say what the button is — the primary action, a secondary one, a quiet text link — and the component maps that onto Material's three button types and the brand colours.
  • A loading state that does not move the layout. The label stays in place at zero alpha with a spinner on top, so the button keeps its size instead of shrinking to the width of a spinner and shoving the screen around mid-tap.
  • A loading button is disabled. The classic double-submitted order — a customer tapping "Place order" twice on a slow network — is prevented here, once, instead of in every screen that has a submit button.
private fun ButtonContent(text: String, isLoading: Boolean) {
    Box(contentAlignment = Alignment.Center) {
        Text(text, modifier = Modifier.alpha(if (isLoading) 0f else 1f))
        if (isLoading) {
            CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
        }
    }
}

Components that encode a rule

The order badge maps every status to a pair of semantic colours, with an exhaustive when:

val (foreground, background) = when (status) {
    OrderStatus.PENDING_PAYMENT -> colors.warning to colors.warningSoft
    OrderStatus.PAID -> colors.info to colors.infoSoft
    OrderStatus.PREPARING -> colors.info to colors.infoSoft
    OrderStatus.COMPLETED -> colors.success to colors.successSoft
    OrderStatus.CANCELLED -> colors.danger to colors.dangerSoft
}

Add a sixth order status to the domain and this file stops compiling until the new status has a colour. A design system that is checked by the compiler does not drift.

The segmented picker is generic over what it picks, so the same component chooses a size in the builder and delivery-or-pickup in the cart, with the compiler checking that each callback gets the right type:

data class Option<T>(val value: T, val label: String, val subtitle: String? = null)
// ...
fun <T> OptionPicker(
    options: List<Option<T>>,
    selected: T,
    onSelect: (T) -> Unit,
    modifier: Modifier = Modifier,
) {

And the shared state views make a rule impossible to forget: an error screen offers "Try again" only when retrying could work, because the button is part of the component, not of each screen:

fun ErrorState(
    message: String,
    isRetryable: Boolean,
    onRetry: () -> Unit,
    modifier: Modifier = Modifier,
) {
    EmptyState(
        emoji = "😕",
        title = "Something went wrong",
        message = message,
        modifier = modifier,
        // A 400 will fail identically on retry, so offering the button would be a lie.
        action = if (isRetryable) {
            { PizzaButton(text = "Try again", onClick = onRetry, style = PizzaButtonStyle.SECONDARY) }
        } else {
            null
        },
    )
}

The rules that keep it a system

  • Features never import Palette. If a screen needs a colour the semantic layer does not have, add the role; do not reach past it.
  • No literal dp in features. Spacing comes from Spacing; sizes that are genuinely one-off (a 64dp product tile) stay in the component that owns them.
  • Components know nothing about pizza. CardContainer, PizzaButton and OptionPicker would work unchanged in a different app. The moment a component takes a Product, it has become a feature.
  • Wrap Material; do not replace it. Every component here is a thin layer over a Material one, so it inherits Material's accessibility, ripple, focus handling and state layers for free.

Next

The menu, the cart and the orders have all come from somewhere. The next lesson is that somewhere: Retrofit and OkHttp, an interceptor that attaches the token, kotlinx.serialization and its three dangerous defaults, and why your first request to a dev server fails with a cleartext error.