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-unstyled modules.
- Configuring Themes using
buildTheme or buildPlatformTheme DSLs.
- Implementing Headless Components (Button, Text, ProgressIndicator, etc.) with custom styling.
- Ensuring Platform-Native Accessibility using interactive size modifiers and platform-specific indications.
Mode Detection
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
Uses platform-specific fonts, sizes, and indications automatically.
kotlin
1val 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).
kotlin
1Button(
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.
kotlin
1val 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.
kotlin
1ProgressIndicator(progress = progress) {
2 Box(Modifier.fillMaxWidth(progress).fillMaxSize().background(contentColor, shape))
3}
- @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
fontFamily in buildPlatformTheme → let it be automatic unless using custom fonts (reason: ensures native platform look-and-feel without extra configuration).
- NEVER store
Theme.currentTheme in variables → always use direct bracket notation Theme[prop][token] (reason: breaks reactivity and prevents state atomicity issues).
- NEVER use
Modifier.clickable for interactive elements → use the Button component instead (reason: Button provides 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
@Preview annotations → every UI component needs a preview with its theme (reason: essential for visual verification and developer efficiency).
- NEVER access tokens via
MaterialTheme.tokens in unstyled modules → use the Unstyled Theme object (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
buildTheme with explicit token values
- Component-specific → Apply Modifier chains to headless components
-
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
-
How do I ensure accessibility?
- Always use
Modifier.interactiveSize() for touch targets (48dp minimum)
- Use
Modifier.indication() for platform-native feedback
- Add
@Preview with theme applied for visual verification
- Test on both Android and iOS for platform consistency
Essential Workflows
To implement a screen with platform-native theming:
- Define the theme using
buildPlatformTheme in your design system module:
kotlin
1val 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
Button component for any interactive element:
kotlin
1@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
interactiveSize to 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:
kotlin
1Button(
2 onClick = {},
3 modifier = Modifier.interactiveSize(Theme[interactiveSizes][sizeDefault])
4) { Text("Accessible Button") }
- Use
sizeMinimum (32dp Android, 28dp iOS) for dense toolbars:
kotlin
1IconButton(
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
ProgressIndicator call:
kotlin
1ProgressIndicator(
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
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:
kotlin
1Column(
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:
kotlin
1val 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.