KMP Compose Unstyled
Headless component library implementation patterns for the Pokédex project. Unstyled components handle UX logic, state, and accessibility while rendering no visual UI by default.
When to Use This Skill
- Building Unstyled UI screens in
:features:<feature>:ui-unstyledmodules. - Configuring Themes using
buildThemeorbuildPlatformThemeDSLs. - Implementing Headless Components (Button, Text, ProgressIndicator, etc.) with custom styling.
- Ensuring Platform-Native Accessibility using interactive size modifiers and platform-specific indications.
Mode Detection
| User Request | Reference File | Load When |
|---|---|---|
| "Create Unstyled component" / "buildPlatformTheme" | compose_unstyled_reference.md | MANDATORY - Read before implementing |
| "Customize tokens" / "CompositionLocal theming" | component_token_customization_example.md | MANDATORY - Read before customizing |
| "Component not working" / "UI issues" | troubleshooting.md | Check for common issues |
MANDATORY - READ ENTIRE FILE: Before implementing Unstyled components with buildPlatformTheme or buildTheme, you MUST read compose_unstyled_reference.md (~1319 lines) for comprehensive component catalog and patterns.
MANDATORY - READ ENTIRE FILE: Before customizing component tokens via CompositionLocal, you MUST read component_token_customization_example.md (~142 lines) for customization patterns.
Do NOT load component_token_customization_example.md for basic component usage - only load when implementing custom token overrides.
Do NOT load troubleshooting.md unless experiencing specific UI component issues.
Core Patterns
1. buildPlatformTheme DSL
Uses platform-specific fonts, sizes, and indications automatically.
kotlin1val PlatformTheme = buildPlatformTheme(name = "MyAppTheme") { 2 defaultContentColor = Color.Black 3 defaultTextStyle = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Normal) 4 // Platform fonts (Roboto, SF Pro) applied automatically 5}
2. Interactive Size Modifier
Ensures touch targets meet platform accessibility standards (Android 48dp, iOS 44dp).
kotlin1Button( 2 onClick = {}, 3 modifier = Modifier.interactiveSize(Theme[interactiveSizes][sizeDefault]) 4) { Text("Accessible Button") }
3. Theme Access Syntax
Always use direct bracket notation for fresh theme reads. Avoid storing theme references.
kotlin1val primary = Theme[colors][primary] // ✅ Direct access 2val body = Theme[typography][bodyMedium]
4. ProgressIndicator Wrapper
Unlike Material, Unstyled ProgressIndicator requires a wrapper to render the fill.
kotlin1ProgressIndicator(progress = progress) { 2 Box(Modifier.fillMaxWidth(progress).fillMaxSize().background(contentColor, shape)) 3}
Related Skills
- @kmp-design-systems - Token system and core component architecture.
- @kmp-architecture - Module structure (Unstyled theme in
:core:designsystem-unstyled). - @compose-screen - General patterns for implementing Compose screens.
- @ui-ux-designer - Visual design and animation guidelines.
Critical Guardrails
- NEVER include Material Design 3 patterns or components in Unstyled modules → keep modules strictly separated (reason: maintains clean architectural boundaries and prevents visual leakage).
- NEVER hardcode platform-specific sizes → always use
Theme[interactiveSizes][sizeDefault](reason: ensures accessibility compliance across Android/iOS/Desktop/Web automatically). - NEVER manual configure
fontFamilyinbuildPlatformTheme→ let it be automatic unless using custom fonts (reason: ensures native platform look-and-feel without extra configuration). - NEVER store
Theme.currentThemein variables → always use direct bracket notationTheme[prop][token](reason: breaks reactivity and prevents state atomicity issues). - NEVER use
Modifier.clickablefor interactive elements → use theButtoncomponent instead (reason:Buttonprovides built-in ARIA support and keyboard interaction logic). - NEVER forget the inner fill Box for
ProgressIndicator→ always implement the rendering block (reason: unlike Material, Unstyled ProgressIndicator renders no UI by default). - NEVER skip
@Previewannotations → every UI component needs a preview with its theme (reason: essential for visual verification and developer efficiency). - NEVER access tokens via
MaterialTheme.tokensin unstyled modules → use the UnstyledThemeobject (reason: prevents tight coupling and breaks dual-theme encapsulation).
Decision Framework
Before implementing Unstyled components, ask yourself:
-
What level of customization is needed?
- Platform-native defaults → Use
buildPlatformTheme(automatic fonts, sizes, indications) - Custom styling → Use
buildThemewith explicit token values - Component-specific → Apply Modifier chains to headless components
- Platform-native defaults → Use
-
Which headless components should I use?
- Interactive elements →
Button,IconButton(handle touch targets, accessibility) - Text display →
Text(platform-native typography) - Loading states →
ProgressIndicator(platform-specific animations) - Custom components → Build with
Modifier.interactiveSize()for accessibility
- Interactive elements →
-
How do I ensure accessibility?
- Always use
Modifier.interactiveSize()for touch targets (48dp minimum) - Use
Modifier.indication()for platform-native feedback - Add
@Previewwith theme applied for visual verification - Test on both Android and iOS for platform consistency
- Always use
Essential Workflows
Workflow 1: Creating an Unstyled Screen with buildPlatformTheme
To implement a screen with platform-native theming:
- Define the theme using
buildPlatformThemein your design system module:kotlin1val UnstyledTheme = buildPlatformTheme(name = "UnstyledTheme") { 2 defaultContentColor = Color(0xFF1C1B1F) 3 defaultTextStyle = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Normal) 4 // Platform fonts (Roboto, SF Pro) applied automatically 5} - Wrap your screen content with the theme object:
kotlin
1@Composable 2fun PokemonListUnstyledScreen(viewModel: PokemonListViewModel) { 3 val uiState by viewModel.uiState.collectAsStateWithLifecycle() 4 UnstyledTheme { 5 PokemonListContent(uiState = uiState) 6 } 7} - Access tokens using bracket notation:
kotlin
1Box(modifier = Modifier.background(Theme[colors][background])) { 2 Text("Pokédex", style = Theme[typography][headlineLarge]) 3}
Related skills: @kmp-design-systems, @compose-screen
Workflow 2: Building Custom Headless Components
To create reusable components using Unstyled primitives:
- Use the
Buttoncomponent for any interactive element:kotlin1@Composable 2fun PokemonCard(pokemon: Pokemon, onClick: () -> Unit) { 3 Button( 4 onClick = onClick, 5 backgroundColor = Theme[colors][surface], 6 contentColor = Theme[colors][onSurface], 7 shape = Theme[shapes][cardShape], 8 modifier = Modifier.interactiveSize(Theme[interactiveSizes][sizeDefault]) 9 ) { 10 Column(horizontalAlignment = Alignment.CenterHorizontally) { 11 AsyncImage(model = pokemon.imageUrl, contentDescription = null) 12 Text(text = pokemon.name, style = Theme[typography][bodyLarge]) 13 } 14 } 15} - Apply
interactiveSizeto ensure accessibility standards are met.
Related skills: @kmp-design-systems, @ui-ux-designer
Workflow 3: Implementing Interactive Size for Accessibility
To ensure your UI meets platform-specific touch target requirements:
- Use
sizeDefault(48dp Android, 44dp iOS) for primary actions:kotlin1Button( 2 onClick = {}, 3 modifier = Modifier.interactiveSize(Theme[interactiveSizes][sizeDefault]) 4) { Text("Accessible Button") } - Use
sizeMinimum(32dp Android, 28dp iOS) for dense toolbars:kotlin1IconButton( 2 onClick = {}, 3 modifier = Modifier.interactiveSize(Theme[interactiveSizes][sizeMinimum]) 4) { Icon(Lucide.Settings, contentDescription = "Settings") }
Related skills: @kmp-architecture, @ui-ux-designer
Workflow 4: Using ProgressIndicator with Custom Styling
To implement a progress bar that requires manual fill rendering:
- Provide the rendering block inside the
ProgressIndicatorcall:kotlin1ProgressIndicator( 2 progress = progress, 3 modifier = Modifier.fillMaxWidth().height(8.dp), 4 shape = RoundedCornerShape(8.dp), 5 backgroundColor = Theme[colors][surface], 6 contentColor = Theme[colors][primary] 7) { 8 // Manual rendering of the progress fill 9 Box( 10 Modifier 11 .fillMaxWidth(progress) 12 .fillMaxSize() 13 .background(contentColor, shape) 14 ) 15}
Related skills: @kmp-developer, @compose-screen
Quick Reference
| Pattern | Purpose | Example |
|---|---|---|
buildPlatformTheme | Platform-native fonts/sizes | buildPlatformTheme(name = "App") { ... } |
Theme[colors][primary] | Direct theme access | val primary = Theme[colors][primary] |
interactiveSize | Accessibility compliance | Modifier.interactiveSize(Theme[interactiveSizes][sizeDefault]) |
ProgressIndicator | Custom fill rendering | ProgressIndicator(progress) { Box(...) } |
ProvideTextStyle | Cascading text styles | ProvideTextStyle(Theme[typography][body]) { ... } |
Cross-References
Skills (by Category)
Design & UI
| Skill | Purpose | Link |
|---|---|---|
| @kmp-design-systems | Token system and component architecture | SKILL.md |
| @ui-ux-designer | Visual design and animation guidelines | SKILL.md |
| @compose-screen | General patterns for implementing Compose screens | SKILL.md |
Implementation
| Skill | Purpose | Link |
|---|---|---|
| @kmp-architecture | Module structure and vertical slicing | SKILL.md |
| @kmp-developer | General development and feature implementation | SKILL.md |
| @kmp-critical-patterns | 6 core patterns quick reference | SKILL.md |
Platform & Navigation
| Skill | Purpose | Link |
|---|---|---|
| @kmp-navigation | Navigation 3 modular architecture | SKILL.md |
| @kmp-testing-patterns | UI and screenshot testing with Roborazzi | SKILL.md |
Documents
| Document | Purpose | Link |
|---|---|---|
| compose_unstyled_patterns.md | Detailed patterns catalog | See @kmp-compose-unstyled skill |
| compose_unstyled_reference.md | Catalog and implementation reference | compose_unstyled_reference.md |
| component_token_customization_example.md | Customization via CompositionLocal | component_token_customization_example.md |
| troubleshooting.md | Common UI component issues and solutions | troubleshooting.md |
| @kmp-architecture | Architecture and development conventions | Architecture skill |
Troubleshooting Common Unstyled Component Issues
Clickable Component Not Responding
Symptom: Card hover/press states work, but clicking does nothing.
Cause: Missing .clickable() modifier despite having MutableInteractionSource.
Solution:
kotlin1Column( 2 modifier = modifier 3 .clip(shape) 4 .border(...) 5 .clickable( // ← REQUIRED for actual clicks 6 interactionSource = interactionSource, 7 indication = null, // Or ripple effect 8 onClick = onClick 9 ) 10 .hoverable(interactionSource = interactionSource) // Only tracks hover 11 .padding(...) 12)
Why: hoverable() only tracks hover state, doesn't make component clickable. Must add .clickable() separately.
Order matters:
.clip()- Define shape first.border()- Visual border.clickable()- Make clickable.hoverable()- Track hover state.padding()- Internal padding
Hover Effects Too Subtle
Symptom: Hover state implemented but barely visible.
Cause: Minimal effect values (brightness 1.1, border alpha 0.2).
Solution for Unstyled theme:
kotlin1val brightness by animateFloatAsState( 2 targetValue = when { 3 isPressed -> 0.95f 4 isHovered -> 1.15f // More noticeable (was 1.1) 5 else -> 1f 6 } 7) 8 9val borderAlpha by animateFloatAsState( 10 targetValue = when { 11 isPressed -> 0.3f 12 isHovered -> 0.5f // More prominent (was 0.2) 13 else -> 0.2f 14 } 15) 16 17val scale by animateFloatAsState( 18 targetValue = when { 19 isPressed -> 0.98f 20 isHovered -> 1.02f // Slight grow (was 1.0) 21 else -> 1f 22 } 23)
Why: Minimal effects match "unstyled" aesthetic but need sufficient visibility for usability.