← Back to list

Building a Design System in Jetpack Compose: Tokens, Theme Engine, Reusable Components, MVI…

Introduction

Ramadan Sayed · 2026-03-10 16:49 · 7 claps · 10.3 min read paywalled
#design-systems #jetpack-compose #mvi
Open on Medium ↗
Wiki topics: PRD · Product Design 📱 · Mobile Development

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