Building a Design System in Jetpack Compose: Tokens, Theme Engine, Reusable Components, MVI…
Introduction
Building a Design System in Jetpack Compose: Tokens, Theme Engine, Reusable Components, MVI Integration, and a Component Library That Scales Across 50 Screens

Introduction
In a 50-screen app, how many different button styles exist? How many shades of gray? How many font sizes? Without a design system, the answer is “nobody knows” — every screen looks slightly different because every developer made their own choices. A design system is a single source of truth: tokens define the visual language, components enforce it, and screens consume it.
This guide covers the complete design system — from foundational tokens through reusable components to MVI-powered screens that consume them. Every component follows the same API pattern: variant + size + optional modifiers + state callbacks.** Friend Link**
Design Tokens: The Foundation
Tokens are named values — not raw numbers. AppSpacing.lg instead of 16.dp. AppTheme.colors.textPrimary instead of Color(0xFF0F172A). When the designer says "make all primary buttons slightly darker," you change one token and 50 screens update.
Color Tokens
// designsystem/tokens/AppColors.kt
@Immutable
data class AppColorScheme(
// Brand
val primary: Color,
val onPrimary: Color,
val primaryContainer: Color,
val onPrimaryContainer: Color,
val secondary: Color,
val onSecondary: Color,
// Surface
val background: Color,
val surface: Color,
val surfaceVariant: Color,
val surfaceElevated: Color,
val onSurface: Color,
val onSurfaceVariant: Color,
// Semantic
val error: Color,
val onError: Color,
val errorContainer: Color,
val success: Color,
val onSuccess: Color,
val successContainer: Color,
val warning: Color,
val onWarning: Color,
val warningContainer: Color,
val info: Color,
// Border
val border: Color,
val borderFocused: Color,
val borderError: Color,
// Text
val textPrimary: Color,
val textSecondary: Color,
val textTertiary: Color,
val textDisabled: Color,
val textLink: Color
)
val LightColors = AppColorScheme(
primary = Color(0xFF2563EB),
onPrimary = Color.White,
primaryContainer = Color(0xFFDBEAFE),
onPrimaryContainer = Color(0xFF1E40AF),
secondary = Color(0xFF7C3AED),
onSecondary = Color.White,
background = Color(0xFFF8FAFC),
surface = Color.White,
surfaceVariant = Color(0xFFF1F5F9),
surfaceElevated = Color.White,
onSurface = Color(0xFF0F172A),
onSurfaceVariant = Color(0xFF475569),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFEE2E2),
success = Color(0xFF16A34A),
onSuccess = Color.White,
successContainer = Color(0xFFDCFCE7),
warning = Color(0xFFD97706),
onWarning = Color.White,
warningContainer = Color(0xFFFEF3C7),
info = Color(0xFF0EA5E9),
border = Color(0xFFE2E8F0),
borderFocused = Color(0xFF2563EB),
borderError = Color(0xFFDC2626),
textPrimary = Color(0xFF0F172A),
textSecondary = Color(0xFF475569),
textTertiary = Color(0xFF94A3B8),
textDisabled = Color(0xFFCBD5E1),
textLink = Color(0xFF2563EB)
)
val DarkColors = AppColorScheme(
primary = Color(0xFF60A5FA),
onPrimary = Color(0xFF0F172A),
primaryContainer = Color(0xFF1E3A5F),
onPrimaryContainer = Color(0xFFBFDBFE),
secondary = Color(0xFFA78BFA),
onSecondary = Color(0xFF0F172A),
background = Color(0xFF0F172A),
surface = Color(0xFF1E293B),
surfaceVariant = Color(0xFF334155),
surfaceElevated = Color(0xFF1E293B),
onSurface = Color(0xFFF1F5F9),
onSurfaceVariant = Color(0xFF94A3B8),
error = Color(0xFFF87171),
onError = Color(0xFF0F172A),
errorContainer = Color(0xFF7F1D1D),
success = Color(0xFF4ADE80),
onSuccess = Color(0xFF0F172A),
successContainer = Color(0xFF14532D),
warning = Color(0xFFFBBF24),
onWarning = Color(0xFF0F172A),
warningContainer = Color(0xFF78350F),
info = Color(0xFF38BDF8),
border = Color(0xFF334155),
borderFocused = Color(0xFF60A5FA),
borderError = Color(0xFFF87171),
textPrimary = Color(0xFFF1F5F9),
textSecondary = Color(0xFF94A3B8),
textTertiary = Color(0xFF64748B),
textDisabled = Color(0xFF475569),
textLink = Color(0xFF60A5FA)
)
Typography Tokens
// designsystem/tokens/AppTypography.kt
@Immutable
data class AppTypographyScheme(
val displayLarge: TextStyle, // Hero text
val displayMedium: TextStyle, // Page title
val headlineLarge: TextStyle, // Section header
val headlineMedium: TextStyle, // Card title
val headlineSmall: TextStyle, // Subsection
val titleLarge: TextStyle, // List item title
val titleMedium: TextStyle, // Button text (large)
val titleSmall: TextStyle, // Overline
val bodyLarge: TextStyle, // Body copy
val bodyMedium: TextStyle, // Secondary text
val bodySmall: TextStyle, // Caption
val labelLarge: TextStyle, // Button text
val labelMedium: TextStyle, // Chip text
val labelSmall: TextStyle // Badge text
)
val AppTypography = AppTypographyScheme(
displayLarge = TextStyle(fontSize = 36.sp, fontWeight = FontWeight.Bold, lineHeight = 44.sp, letterSpacing = (-0.5).sp),
displayMedium = TextStyle(fontSize = 28.sp, fontWeight = FontWeight.Bold, lineHeight = 36.sp),
headlineLarge = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.SemiBold, lineHeight = 32.sp),
headlineMedium = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.SemiBold, lineHeight = 28.sp),
headlineSmall = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, lineHeight = 24.sp),
titleLarge = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.SemiBold, lineHeight = 24.sp),
titleMedium = TextStyle(fontSize = 14.sp, fontWeight = FontWeight.SemiBold, lineHeight = 20.sp),
titleSmall = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold, lineHeight = 16.sp, letterSpacing = 0.5.sp),
bodyLarge = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Normal, lineHeight = 24.sp),
bodyMedium = TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Normal, lineHeight = 20.sp),
bodySmall = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Normal, lineHeight = 16.sp),
labelLarge = TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp),
labelMedium = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium, lineHeight = 16.sp),
labelSmall = TextStyle(fontSize = 10.sp, fontWeight = FontWeight.Medium, lineHeight = 14.sp, letterSpacing = 0.5.sp)
)
Spacing and Shape Tokens
// designsystem/tokens/AppSpacing.kt
object AppSpacing {
val xxxs = 2.dp; val xxs = 4.dp; val xs = 6.dp
val sm = 8.dp; val md = 12.dp; val lg = 16.dp
val xl = 20.dp; val xxl = 24.dp; val xxxl = 32.dp
val xxxxl = 40.dp; val jumbo = 48.dp
}
// designsystem/tokens/AppShapes.kt
object AppShapes {
val extraSmall = RoundedCornerShape(4.dp)
val small = RoundedCornerShape(8.dp)
val medium = RoundedCornerShape(12.dp)
val large = RoundedCornerShape(16.dp)
val extraLarge = RoundedCornerShape(24.dp)
val full = RoundedCornerShape(50)
}
// designsystem/tokens/AppElevation.kt
object AppElevation {
val none = 0.dp
val low = 2.dp
val medium = 4.dp
val high = 8.dp
val highest = 16.dp
}
Theme Engine with CompositionLocal
// designsystem/theme/AppTheme.kt
val LocalAppColors = staticCompositionLocalOf { LightColors }
val LocalAppTypography = staticCompositionLocalOf { AppTypography }
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) DarkColors else LightColors
CompositionLocalProvider(
LocalAppColors provides colors,
LocalAppTypography provides AppTypography
) {
MaterialTheme(content = content)
}
}
// Access anywhere via AppTheme object
object AppTheme {
val colors: AppColorScheme @Composable get() = LocalAppColors.current
val typography: AppTypographyScheme @Composable get() = LocalAppTypography.current
}
Usage: Text("Hello", color = AppTheme.colors.textPrimary, style = AppTheme.typography.headlineMedium) — always consistent, always themeable.
Reusable Components
AppButton (4 Variants × 3 Sizes)
// designsystem/components/AppButton.kt
enum class ButtonVariant { Filled, Outlined, Ghost, Destructive }
enum class ButtonSize { Small, Medium, Large }
@Composable
fun AppButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: ButtonVariant = ButtonVariant.Filled,
size: ButtonSize = ButtonSize.Medium,
enabled: Boolean = true,
loading: Boolean = false,
leadingIcon: ImageVector? = null,
trailingIcon: ImageVector? = null
) {
val colors = AppTheme.colors
val containerColor = when (variant) {
ButtonVariant.Filled -> colors.primary
ButtonVariant.Outlined -> Color.Transparent
ButtonVariant.Ghost -> Color.Transparent
ButtonVariant.Destructive -> colors.error
}
val contentColor = when (variant) {
ButtonVariant.Filled -> colors.onPrimary
ButtonVariant.Outlined -> colors.primary
ButtonVariant.Ghost -> colors.primary
ButtonVariant.Destructive -> colors.onError
}
val height = when (size) {
ButtonSize.Small -> 36.dp
ButtonSize.Medium -> 44.dp
ButtonSize.Large -> 52.dp
}
val textStyle = when (size) {
ButtonSize.Small -> AppTheme.typography.labelMedium
ButtonSize.Medium -> AppTheme.typography.labelLarge
ButtonSize.Large -> AppTheme.typography.titleMedium
}
val horizontalPadding = when (size) {
ButtonSize.Small -> AppSpacing.md
ButtonSize.Medium -> AppSpacing.lg
ButtonSize.Large -> AppSpacing.xl
}
Button(
onClick = onClick,
enabled = enabled && !loading,
modifier = modifier.height(height),
shape = AppShapes.medium,
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = containerColor.copy(alpha = 0.4f),
disabledContentColor = contentColor.copy(alpha = 0.4f)
),
border = if (variant == ButtonVariant.Outlined) BorderStroke(1.5.dp, colors.border) else null,
elevation = if (variant == ButtonVariant.Ghost || variant == ButtonVariant.Outlined)
ButtonDefaults.buttonElevation(0.dp) else ButtonDefaults.buttonElevation(),
contentPadding = PaddingValues(horizontal = horizontalPadding)
) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = contentColor
)
Spacer(Modifier.width(AppSpacing.sm))
}
leadingIcon?.let {
Icon(it, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(AppSpacing.sm))
}
Text(text, style = textStyle)
trailingIcon?.let {
Spacer(Modifier.width(AppSpacing.sm))
Icon(it, contentDescription = null, modifier = Modifier.size(18.dp))
}
}
}
AppTextField (With Error, Label, Icons)
// designsystem/components/AppTextField.kt
@Composable
fun AppTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
placeholder: String? = null,
helperText: String? = null,
errorMessage: String? = null,
leadingIcon: ImageVector? = null,
trailingIcon: ImageVector? = null,
onTrailingIconClick: (() -> Unit)? = null,
singleLine: Boolean = true,
enabled: Boolean = true,
readOnly: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
val colors = AppTheme.colors
val isError = errorMessage != null
Column(modifier = modifier) {
// Label
label?.let {
Text(
text = it,
style = AppTheme.typography.labelMedium,
color = if (isError) colors.error else colors.textSecondary,
modifier = Modifier.padding(bottom = AppSpacing.xxs)
)
}
// Text field
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
placeholder = placeholder?.let { { Text(it, color = colors.textTertiary) } },
leadingIcon = leadingIcon?.let { icon ->
{ Icon(icon, contentDescription = null, tint = colors.textTertiary, modifier = Modifier.size(20.dp)) }
},
trailingIcon = trailingIcon?.let { icon ->
{
IconButton(onClick = onTrailingIconClick ?: {}, modifier = Modifier.size(20.dp)) {
Icon(icon, contentDescription = null, tint = colors.textTertiary)
}
}
},
isError = isError,
enabled = enabled,
readOnly = readOnly,
singleLine = singleLine,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
visualTransformation = visualTransformation,
shape = AppShapes.small,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = colors.borderFocused,
unfocusedBorderColor = colors.border,
errorBorderColor = colors.borderError,
focusedContainerColor = colors.surface,
unfocusedContainerColor = colors.surface,
disabledContainerColor = colors.surfaceVariant
),
textStyle = AppTheme.typography.bodyMedium.copy(color = colors.textPrimary)
)
// Helper text / Error message
val supportText = errorMessage ?: helperText
supportText?.let {
Text(
text = it,
style = AppTheme.typography.bodySmall,
color = if (isError) colors.error else colors.textTertiary,
modifier = Modifier.padding(top = AppSpacing.xxs, start = AppSpacing.xxs)
)
}
}
}
AppCard, AppAvatar, AppBadge, AppChip, AppDivider
// designsystem/components/AppCard.kt
@Composable
fun AppCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
elevated: Boolean = false,
content: @Composable ColumnScope.() -> Unit
) {
Surface(
modifier = modifier,
onClick = onClick ?: {},
enabled = onClick != null,
shape = AppShapes.large,
color = AppTheme.colors.surface,
border = if (!elevated) BorderStroke(1.dp, AppTheme.colors.border) else null,
shadowElevation = if (elevated) AppElevation.medium else AppElevation.none
) {
Column(modifier = Modifier.padding(AppSpacing.lg), content = content)
}
}
// designsystem/components/AppAvatar.kt
enum class AvatarSize(val dp: Dp) { Small(32.dp), Medium(44.dp), Large(64.dp), XLarge(96.dp) }
@Composable
fun AppAvatar(
imageUrl: String?,
size: AvatarSize = AvatarSize.Medium,
fallbackText: String = "?",
isOnline: Boolean = false,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.size(size.dp)) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize().clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier.fillMaxSize().background(AppTheme.colors.primaryContainer, CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = fallbackText.take(2).uppercase(),
style = when (size) {
AvatarSize.Small -> AppTheme.typography.labelSmall
AvatarSize.Medium -> AppTheme.typography.titleMedium
AvatarSize.Large -> AppTheme.typography.headlineMedium
AvatarSize.XLarge -> AppTheme.typography.displayMedium
},
color = AppTheme.colors.onPrimaryContainer
)
}
}
if (isOnline) {
Box(
modifier = Modifier
.size(if (size.dp > 44.dp) 14.dp else 10.dp)
.align(Alignment.BottomEnd)
.background(AppTheme.colors.success, CircleShape)
.border(2.dp, AppTheme.colors.surface, CircleShape)
)
}
}
}
// designsystem/components/AppBadge.kt
@Composable
fun AppBadge(
count: Int,
modifier: Modifier = Modifier,
maxCount: Int = 99
) {
if (count > 0) {
Box(
modifier = modifier
.background(AppTheme.colors.error, AppShapes.full)
.padding(horizontal = AppSpacing.xs, vertical = AppSpacing.xxxs),
contentAlignment = Alignment.Center
) {
Text(
text = if (count > maxCount) "$maxCount+" else "$count",
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onError
)
}
}
}
// designsystem/components/AppChip.kt
@Composable
fun AppChip(
text: String,
selected: Boolean = false,
onClick: () -> Unit = {},
leadingIcon: ImageVector? = null,
dismissible: Boolean = false,
onDismiss: () -> Unit = {}
) {
Surface(
onClick = onClick,
shape = AppShapes.full,
color = if (selected) AppTheme.colors.primary else AppTheme.colors.surfaceVariant,
border = if (!selected) BorderStroke(1.dp, AppTheme.colors.border) else null
) {
Row(
modifier = Modifier.padding(horizontal = AppSpacing.md, vertical = AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
leadingIcon?.let {
Icon(it, contentDescription = null, modifier = Modifier.size(16.dp),
tint = if (selected) AppTheme.colors.onPrimary else AppTheme.colors.textSecondary)
}
Text(
text = text,
style = AppTheme.typography.labelMedium,
color = if (selected) AppTheme.colors.onPrimary else AppTheme.colors.textSecondary
)
if (dismissible) {
Icon(Icons.Default.Close, contentDescription = "Remove",
modifier = Modifier.size(14.dp).clickable { onDismiss() },
tint = if (selected) AppTheme.colors.onPrimary else AppTheme.colors.textTertiary)
}
}
}
}
// designsystem/components/AppDivider.kt
@Composable
fun AppDivider(modifier: Modifier = Modifier) {
HorizontalDivider(
modifier = modifier,
thickness = 1.dp,
color = AppTheme.colors.border
)
}
Integrating Components with MVI Screens
Here’s how a real screen consumes the design system with MVI architecture:
Login Screen Example
// presentation/auth/LoginUiState.kt
data class LoginUiState(
val email: String = "",
val password: String = "",
val emailError: String? = null,
val passwordError: String? = null,
val isLoading: Boolean = false,
val isPasswordVisible: Boolean = false
)
sealed interface LoginEvent {
data class EmailChanged(val email: String) : LoginEvent
data class PasswordChanged(val password: String) : LoginEvent
data object TogglePasswordVisibility : LoginEvent
data object LoginClicked : LoginEvent
data object GoogleSignInClicked : LoginEvent
}
// presentation/auth/LoginViewModel.kt
@HiltViewModel
class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase
) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
private val _effect = Channel<LoginEffect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()
fun onEvent(event: LoginEvent) {
when (event) {
is LoginEvent.EmailChanged -> _state.update { it.copy(email = event.email, emailError = null) }
is LoginEvent.PasswordChanged -> _state.update { it.copy(password = event.password, passwordError = null) }
is LoginEvent.TogglePasswordVisibility -> _state.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
is LoginEvent.LoginClicked -> login()
is LoginEvent.GoogleSignInClicked -> { /* Google sign-in */ }
}
}
private fun login() {
val email = _state.value.email
val password = _state.value.password
// Validate
var hasError = false
if (!email.contains("@")) {
_state.update { it.copy(emailError = "Invalid email address") }
hasError = true
}
if (password.length < 8) {
_state.update { it.copy(passwordError = "Password must be at least 8 characters") }
hasError = true
}
if (hasError) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
when (val result = loginUseCase(email, password)) {
is Resource.Success -> _effect.send(LoginEffect.NavigateToHome)
is Resource.Error -> _effect.send(LoginEffect.ShowError(result.message))
is Resource.Loading -> { }
}
_state.update { it.copy(isLoading = false) }
}
}
}
sealed interface LoginEffect {
data object NavigateToHome : LoginEffect
data class ShowError(val message: String) : LoginEffect
}
// presentation/auth/LoginScreen.kt
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
onNavigateToHome: () -> Unit
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is LoginEffect.NavigateToHome -> onNavigateToHome()
is LoginEffect.ShowError -> snackbarHostState.showSnackbar(effect.message)
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = AppSpacing.xxl)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(AppSpacing.xxxxl))
// Title - uses AppTheme.typography
Text(
"Welcome back",
style = AppTheme.typography.displayMedium,
color = AppTheme.colors.textPrimary
)
Spacer(Modifier.height(AppSpacing.sm))
Text(
"Sign in to continue shopping",
style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.textSecondary
)
Spacer(Modifier.height(AppSpacing.xxxl))
// Email - uses AppTextField from design system
AppTextField(
value = state.email,
onValueChange = { viewModel.onEvent(LoginEvent.EmailChanged(it)) },
label = "Email",
placeholder = "you@example.com",
errorMessage = state.emailError,
leadingIcon = Icons.Default.Email,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
Spacer(Modifier.height(AppSpacing.lg))
// Password - uses AppTextField with toggle visibility
AppTextField(
value = state.password,
onValueChange = { viewModel.onEvent(LoginEvent.PasswordChanged(it)) },
label = "Password",
placeholder = "Enter your password",
errorMessage = state.passwordError,
leadingIcon = Icons.Default.Lock,
trailingIcon = if (state.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
onTrailingIconClick = { viewModel.onEvent(LoginEvent.TogglePasswordVisibility) },
visualTransformation = if (state.isPasswordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Spacer(Modifier.height(AppSpacing.sm))
// Forgot password link
Text(
"Forgot password?",
style = AppTheme.typography.labelMedium,
color = AppTheme.colors.textLink,
modifier = Modifier.align(Alignment.End).clickable { /* navigate */ }
)
Spacer(Modifier.height(AppSpacing.xxl))
// Login button - uses AppButton with loading state
AppButton(
text = "Sign In",
onClick = { viewModel.onEvent(LoginEvent.LoginClicked) },
modifier = Modifier.fillMaxWidth(),
size = ButtonSize.Large,
loading = state.isLoading
)
Spacer(Modifier.height(AppSpacing.lg))
// Divider with "or"
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
AppDivider(Modifier.weight(1f))
Text(
"or",
modifier = Modifier.padding(horizontal = AppSpacing.lg),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.textTertiary
)
AppDivider(Modifier.weight(1f))
}
Spacer(Modifier.height(AppSpacing.lg))
// Google sign-in - uses AppButton outlined variant
AppButton(
text = "Continue with Google",
onClick = { viewModel.onEvent(LoginEvent.GoogleSignInClicked) },
modifier = Modifier.fillMaxWidth(),
variant = ButtonVariant.Outlined,
size = ButtonSize.Large,
leadingIcon = Icons.Default.AccountCircle
)
Spacer(Modifier.height(AppSpacing.xxl))
// Sign up link
Row {
Text("Don't have an account? ", style = AppTheme.typography.bodyMedium, color = AppTheme.colors.textSecondary)
Text("Sign Up", style = AppTheme.typography.labelLarge, color = AppTheme.colors.textLink,
modifier = Modifier.clickable { /* navigate to register */ })
}
Spacer(Modifier.height(AppSpacing.xxxl))
}
}
}
Every piece of the login screen uses the design system: AppTheme.typography.displayMedium for the title, AppTheme.colors.textSecondary for subtitles, AppTextField with error handling, AppButton with loading state and variants, AppDivider, and AppSpacing for all gaps. Change the primary color once → the entire app updates.
Dynamic Theme Switching
// In your App/Activity
@Composable
fun MyApp() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val isDarkMode by settingsViewModel.isDarkMode.collectAsStateWithLifecycle()
AppTheme(darkTheme = isDarkMode) {
AppNavigation()
}
}
Conclusion
A design system transforms 50 inconsistent screens into one cohesive app:
Design Tokens: Named values (AppSpacing.lg, AppTheme.colors.primary) replace magic numbers CompositionLocal Theme: Dark/light switching — one line changes everything Component API Pattern: variant (Filled/Outlined/Ghost) × size (S/M/L) × state (loading/error/disabled) MVI Integration: Components receive state from UiState, emit events to ViewModel Real Screen Example: Login screen consuming AppButton, AppTextField, AppDivider, AppTheme tokens Consistency: Every screen references the same tokens — impossible to drift
Connect with Me on LinkedIn
Follow me on LinkedIn
Tags: #DesignSystem #JetpackCompose #Theming #MVI #ComponentLibrary #Tokens #Android #Kotlin
메타데이터
- post_id
- 6f4707ecaaa3
- slug
- building-a-design-system-in-jetpack-compose-tokens-theme-engine-reusable-components-mvi-6f4707ecaaa3
- url
- https://medium.com/@ramadan123sayed/building-a-design-system-in-jetpack-compose-tokens-theme-engine-reusable-components-mvi-6f4707ecaaa3
- canonical_url
- https://medium.com/@ramadan123sayed/building-a-design-system-in-jetpack-compose-tokens-theme-engine-reusable-components-mvi-6f4707ecaaa3
- author_url
- https://medium.com/@ramadan123sayed
- status
- ok
- fetched_at
- 2026-06-12 07:40:50