---
title: "Building a Design System with Jetpack Compose"
description: "Build a scalable Compose design system: design tokens as the foundation, Material 3 extended with custom CompositionLocals, and an atomic component library."
url: https://android-x.com/blog/jetpack-compose-design-system
author: "Rocky Elsalaymeh"
publisher: "Strategia-X (ANDROID-ARCHITECT)"
published: 2026-02-20
updated: 2026-10-11
category: "Jetpack Compose"
reading_time: "10 min read"
---

# Building a Design System with Jetpack Compose

> Build a scalable Compose design system: design tokens as the foundation, Material 3 extended with custom CompositionLocals, and an atomic component library.

By Rocky Elsalaymeh. Published 2026-02-20, updated 2026-10-11. Canonical page: https://android-x.com/blog/jetpack-compose-design-system

## Why Your App Needs a Design System

Without a design system, every screen is a set of private decisions. One developer pads a button by 12dp, another by 16. Text sizes drift. A colour is copied as a hex value into forty files, and changing it becomes a search-and-replace with a prayer. Nobody chose inconsistency. It is what happens when the decision has no home.

A design system gives each decision one home. [Jetpack Compose](https://developer.android.com/develop/ui/compose) makes that cheaper than XML ever did, because a theme is ordinary Kotlin: values, functions and types you can read, test and refactor.

You also do not have to start from nothing or accept Material whole. The [custom design systems guide](https://developer.android.com/develop/ui/compose/designsystems/custom) is explicit that "Material is built entirely on public APIs, so it's possible to create your own design system in the same manner", and lists three levels of departure. This article builds the first, which is the right one for most products.

## What belongs in a Compose design system?

Three layers, each depending only on the one below it:

1. **Tokens.** The raw decisions: colours, type scale, spacing, shapes.
2. **Theme.** The mechanism that delivers tokens to any composable, with a light and a dark set.
3. **Components.** Buttons, fields and cards that read tokens and accept only the options you are prepared to support.

How far to move from Material is the first real choice. The guide's three options, in its words:

| Approach | When it fits |
|---|---|
| "Extend MaterialTheme with additional theming values." | You like Material and need a few things it lacks, such as a success colour |
| "Replace one or more Material systems" while keeping the others | Your type or shape system is your own, your colours are not |
| "Implement a fully custom design system to replace MaterialTheme." | The brand shares nothing with Material |

Extending keeps every Material component working with sensible defaults, which is why it is the default recommendation here. Material itself has moved: the Compose implementation now covers Material 3 Expressive, which the [Material 3 guide](https://developer.android.com/develop/ui/compose/designsystems/material3) describes as "an expansion of Material Design 3". If you extend, you inherit that work.

## Design Tokens: The Foundation

Material 3 already gives you a colour scheme, a type scale and shapes. Most apps need a little more: semantic colours such as success and warning, and a spacing scale.

For one or two extra values the guide suggests the lightest tool: "The simplest approach is to add extension properties". For a set of related values, define a class and deliver it through a [CompositionLocal](https://developer.android.com/develop/ui/compose/compositionlocal), which is what the sample below does.

Two details in the sample are deliberate:

- **`staticCompositionLocalOf`, not `compositionLocalOf`.** The documentation's guidance: "If the value provided to the CompositionLocal is highly unlikely to change or will never change, use staticCompositionLocalOf to get performance benefits." Theme colours change when the system theme flips and otherwise never. The trade is stated too: a change "causes the entirety of the content lambda where the CompositionLocal is provided to be recomposed". For a theme switch that is acceptable. For a value that changes often it would not be.
- **Defaults that are obviously wrong.** The default instance uses `Color.Unspecified`, as the guide's own sample does. A real colour as a default hides the bug where a screen was never wrapped in the theme. An unspecified one makes it visible on the first run.

Spacing here is a plain object. It does not vary by theme, so it does not need a CompositionLocal. The 8dp step is our convention, not a platform rule. What matters is that there is one scale.

```kotlin
// Semantic colours Material 3 does not define
@Immutable
data class ExtendedColors(
    val success: Color,
    val onSuccess: Color,
    val warning: Color,
    val onWarning: Color,
    val borderSubtle: Color,
)

// Defaults are deliberately unusable: a screen outside AppTheme shows it
val LocalExtendedColors = staticCompositionLocalOf {
    ExtendedColors(
        success = Color.Unspecified,
        onSuccess = Color.Unspecified,
        warning = Color.Unspecified,
        onWarning = Color.Unspecified,
        borderSubtle = Color.Unspecified,
    )
}

// One spacing scale for the whole app
object Spacing {
    val xxs = 4.dp
    val xs = 8.dp
    val sm = 12.dp
    val md = 16.dp
    val lg = 24.dp
    val xl = 32.dp
    val xxl = 48.dp
}
```

## Building the Theme Provider

The theme function does two jobs: it picks the token set for the current mode and it provides the extended tokens around `MaterialTheme`. The accessor object with the same name is the guide's pattern, and it reads the way `MaterialTheme.colorScheme` does: "This is similar to MaterialTheme usage APIs."

Dynamic colour is a decision to make on purpose. It takes the palette from the user's wallpaper, and "Dynamic color is available on Android 12 and above." If your brand colour must stay your brand colour, leave it off. If you turn it on, your extended colours do not change with it, so check them against a few wallpapers for contrast. The numbers to hit are in the [accessibility guide](https://android-x.com/blog/jetpack-compose-accessibility).

```kotlin
private val LightExtended = ExtendedColors(
    success = Color(0xFF2E7D32),
    onSuccess = Color.White,
    warning = Color(0xFFF9A825),
    onWarning = Color.Black,
    borderSubtle = Color(0xFFE0E0E0),
)

private val DarkExtended = ExtendedColors(
    success = Color(0xFF66BB6A),
    onSuccess = Color.Black,
    warning = Color(0xFFFFCA28),
    onWarning = Color.Black,
    borderSubtle = Color(0xFF2C2F33),
)

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = false, // on by choice, not by default
    content: @Composable () -> Unit
) {
    // Dynamic color is available on Android 12+
    val supportsDynamic = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
    val colorScheme = when {
        dynamicColor && supportsDynamic && darkTheme ->
            dynamicDarkColorScheme(LocalContext.current)
        dynamicColor && supportsDynamic && !darkTheme ->
            dynamicLightColorScheme(LocalContext.current)
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }

    CompositionLocalProvider(
        LocalExtendedColors provides if (darkTheme) DarkExtended else LightExtended
    ) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = AppTypography,
            shapes = AppShapes,
            content = content,
        )
    }
}

// Use as AppTheme.extendedColors.success
object AppTheme {
    val extendedColors: ExtendedColors
        @Composable get() = LocalExtendedColors.current
}
```

## Component Library: Atoms to Organisms

Atomic design is a naming scheme, and it is useful as one. It is our recommendation, not something Compose prescribes:

- **Atoms**: `AppButton`, `AppText`, `AppIcon`.
- **Molecules**: a search bar made of a field, an icon and a clear button.
- **Organisms**: a profile card made of an avatar, a name and actions.

The rules that make the library worth having are stricter than the names:

1. **Components read tokens.** A literal colour or dp value inside a component is a decision that escaped the system. The one exception in the sample, an 18dp icon, is the kind of thing to lift into a token the second time you need it.
2. **Offer fewer options than Material does.** `AppButton` has three variants and three sizes. That narrowness is the product. A wrapper that forwards every parameter is not a design system.
3. **Take a `modifier` and apply it to the root.** It is the one escape hatch callers need.
4. **Read the shape from the theme.** The earlier sample used `AppShapes.medium` directly, which bypasses any nested theme. `MaterialTheme.shapes.medium` respects it.

Because every interactive component lives here, this is also where you guarantee a minimum touch target once instead of on every screen.

```kotlin
@Composable
fun AppButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    variant: ButtonVariant = ButtonVariant.Primary,
    size: ButtonSize = ButtonSize.Medium,
    enabled: Boolean = true,
    leadingIcon: ImageVector? = null,
) {
    val colors = when (variant) {
        ButtonVariant.Primary -> ButtonDefaults.buttonColors()
        ButtonVariant.Success -> ButtonDefaults.buttonColors(
            containerColor = AppTheme.extendedColors.success,
            contentColor = AppTheme.extendedColors.onSuccess,
        )
        ButtonVariant.Danger -> ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.error,
            contentColor = MaterialTheme.colorScheme.onError,
        )
    }

    val padding = when (size) {
        ButtonSize.Small -> PaddingValues(
            horizontal = Spacing.sm, vertical = Spacing.xxs
        )
        ButtonSize.Medium -> PaddingValues(
            horizontal = Spacing.md, vertical = Spacing.xs
        )
        ButtonSize.Large -> PaddingValues(
            horizontal = Spacing.lg, vertical = Spacing.sm
        )
    }

    Button(
        onClick = onClick,
        modifier = modifier,
        enabled = enabled,
        colors = colors,
        contentPadding = padding,
        shape = MaterialTheme.shapes.medium,
    ) {
        if (leadingIcon != null) {
            Icon(
                imageVector = leadingIcon,
                contentDescription = null, // the button text is the label
                modifier = Modifier.size(18.dp),
            )
            Spacer(Modifier.width(Spacing.xs))
        }
        Text(text = text)
    }
}

enum class ButtonVariant { Primary, Success, Danger }
enum class ButtonSize { Small, Medium, Large }
```

## Previews, Screenshots and Keeping It Honest

A [preview](https://developer.android.com/develop/ui/compose/tooling/previews) per component, in every variant, is the catalogue. The multipreview templates remove the boilerplate: annotations such as `@PreviewLightDark` and `@PreviewFontScales` exist "so that with one single annotation, you can preview your Compose UI in common scenarios".

A catalogue nobody checks goes stale. Turn the previews into tests. Android's testing guidance is that "Screenshot testing is the recommended way to verify visual attributes in Compose UIs" ([Android](https://developer.android.com/training/testing/ui-tests/screenshot)), and the official tool renders the same `@Preview` functions; the setup is in the [screenshot testing guide](https://android-x.com/blog/compose-screenshot-testing). Then a token change shows up as a reviewed image diff on every component it touched, and a change nobody intended fails the build.

One correction to the earlier sample: it used `Icons.Default.ArrowForward`, which is deprecated with the note "Use the AutoMirrored version at Icons.AutoMirrored.Filled.ArrowForward" ([reference](https://developer.android.com/reference/kotlin/androidx/compose/material/icons/Icons.Filled)). Directional icons have to flip in right-to-left layouts, and the auto-mirrored set does that.

```kotlin
@PreviewLightDark
@Composable
private fun PrimaryButtonPreview() {
    AppTheme {
        AppButton(
            text = "Get Started",
            onClick = {},
            leadingIcon = Icons.AutoMirrored.Filled.ArrowForward,
        )
    }
}

@PreviewLightDark
@PreviewFontScales
@Composable
private fun AllButtonsPreview() {
    AppTheme {
        Column(
            verticalArrangement = Arrangement.spacedBy(Spacing.sm)
        ) {
            ButtonVariant.entries.forEach { variant ->
                ButtonSize.entries.forEach { size ->
                    AppButton(
                        text = "${variant.name} ${size.name}",
                        onClick = {},
                        variant = variant,
                        size = size,
                    )
                }
            }
        }
    }
}
```

## Key takeaways

- A design system gives each visual decision one home: tokens, then a theme, then components.
- Extend MaterialTheme before replacing it. Material components keep working and you add only what is missing.
- Deliver custom tokens with staticCompositionLocalOf and give the default instance unusable values such as Color.Unspecified.
- Components read tokens and offer fewer options than Material. Narrowness is the point.
- Dynamic colour needs Android 12 and is a choice: your extended colours do not follow the wallpaper.
- Previews are the catalogue, and screenshot tests are what keep the catalogue true.

## Frequently asked questions

### What are design tokens in a Compose design system?

Tokens are the named raw decisions of the visual language: colours, type sizes, spacing steps and shapes. Components read tokens and never hold literal values, so changing a token changes every component that uses it. In Compose they live in the Material theme, plus your own classes delivered through a CompositionLocal.

### How do I extend Material 3 with my own tokens?

Define an immutable class for the extra values, create a staticCompositionLocalOf for it, and provide an instance inside your theme function around MaterialTheme. Expose it through an accessor object so call sites read AppTheme.extendedColors.success. For one or two values, extension properties on ColorScheme are simpler still.

### Should I build a fully custom design system or extend Material?

Extend Material unless your brand shares nothing with it. Extending keeps every Material component usable with reasonable defaults and inherits future Material work. Replacing a subsystem, or the whole theme, means you also own how each Material component you still use gets its colours, type and shapes, which is ongoing maintenance.

## Related articles

- [Accessibility in Jetpack Compose: Building Apps Everyone Can Use](https://android-x.com/blog/jetpack-compose-accessibility.md)
- [Your UI Tests Pass While Your UI Breaks: Screenshot Testing for Compose](https://android-x.com/blog/compose-screenshot-testing.md)
- [Compose Animation Masterclass: From Micro-Interactions to Shared Element Transitions](https://android-x.com/blog/compose-animation-masterclass.md)
