Why Your App Needs a Design System
What belongs in a Compose design system?
| 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 |
Design Tokens: The Foundation
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.// 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
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.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
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.@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
@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), and the official tool renders the same @Preview functions; the setup is in the screenshot testing guide. 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). Directional icons have to flip in right-to-left layouts, and the auto-mirrored set does that.@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
- 1A design system gives each visual decision one home: tokens, then a theme, then components.
- 2Extend MaterialTheme before replacing it. Material components keep working and you add only what is missing.
- 3Deliver custom tokens with staticCompositionLocalOf and give the default instance unusable values such as Color.Unspecified.
- 4Components read tokens and offer fewer options than Material. Narrowness is the point.
- 5Dynamic colour needs Android 12 and is a choice: your extended colours do not follow the wallpaper.
- 6Previews are the catalogue, and screenshot tests are what keep the catalogue true.
Frequently Asked
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.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.