Adaptive UI for Kotlin Multiplatform — WindowSizeClass-driven layouts that respond correctly to Compact (phone), Medium (tablet), and Expanded (desktop) breakpoints. Covers list-detail splits, adaptive navigation (bottom bar → rail → drawer), single-source WindowSizeClass propagation, and Roborazzi tests for each breakpoint. Enforces cross-session pattern consistency so every screen in the project uses the same adaptive strategy.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ronjunevaldoz/kmp-agent-skills --skill kmp-compose-adaptive-layout --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Kmp Compose Adaptive Layout?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ronjunevaldoz-kmp-compose-adaptive-layout)More formats (shields.io, HTML) on the badges page.
---
name: kmp-compose-adaptive-layout
description: >-
Adaptive UI for Kotlin Multiplatform — WindowSizeClass-driven layouts that
respond correctly to Compact (phone), Medium (tablet), and Expanded (desktop)
breakpoints. Covers list-detail splits, adaptive navigation (bottom bar → rail →
drawer), single-source WindowSizeClass propagation, and Roborazzi tests for each
breakpoint. Enforces cross-session pattern consistency so every screen in the project
uses the same adaptive strategy.
license: Apache-2.0
metadata:
author: kmp-agent-skills
last-updated: '2026-07-07'
keywords:
- adaptive layout
- WindowSizeClass
- WindowWidthSizeClass
- responsive UI
- Compact Medium Expanded
- ListDetailPane
- adaptive navigation
- navigation rail
- navigation drawer
- tablet layout
- desktop layout
- split screen
- list detail
- side panel
- calculateWindowSizeClass
---
## When to Use This Skill
Use when:
- Implementing any screen that needs a different layout on phone vs tablet vs desktop
- Adding list-detail (master-detail) split navigation
- Adapting navigation chrome (bottom bar → rail → drawer)
- A new session is started after adaptive layout was already established — check the
existing pattern before writing new screens
- The reviewer flags `[ADAPTIVE] inconsistency` — one screen uses WindowSizeClass, a
newly added screen does not
**Trigger keywords:** adaptive layout, responsive layout, WindowSizeClass, ListDetailPane,
tablet layout, desktop layout, mobile layout, phone layout, split screen, detail split,
list detail, side panel, navigation rail, navigation drawer, window size,
Compact Medium Expanded, adaptive navigation, calculateWindowSizeClass,
different layout phone tablet, different layout phone desktop, responsive composable,
multi-pane layout, master detail KMP, pane layout, screen size breakpoint,
layout per screen size, detect all screen layouts, all screen sizes,
page layout, layout consistency, consistent layout, screen consistency,
redesign page, layout redesign, page consistency, uniform layout, layout patterns,
FlexBox, CSS flexbox, flex container, flex item, flex wrap, flex grow,
flex shrink, flex basis, justifyContent, alignItems, alignContent, Modifier.flex,
wrapping row, wrapping layout, chip wrap layout.
**Freshness rule:** `material3-adaptive` is still evolving — recheck the API when upgrading
`androidx.compose.material3.adaptive`. `calculateWindowSizeClass()` moved packages between
CMP releases; verify the import against the current version in `libs.versions.toml`.
## Compose Layout Fundamentals
- https://developer.android.com/develop/ui/compose/layouts — official Compose layout overview; use this before picking a container or custom pattern
- https://developer.android.com/develop/ui/compose/layouts/adaptive/get-started-with-adaptive-apps — official adaptive-app entry point for phones, tablets, foldables, and other form factors
- https://developer.android.com/develop/ui/compose/layouts/adaptive/canonical-layouts — canonical adaptive layout patterns for list-detail, supporting pane, and related responsive structures
- https://developer.android.com/develop/ui/compose/layouts/adaptive/build-adaptive-navigation — adaptive navigation patterns for bottom bar, rail, drawer, and multi-pane navigation shells
- https://developer.android.com/develop/adaptive-apps/guides/flexbox — FlexBox overview: an experimental CSS-flexbox-equivalent container for responsive, wrapping, single-axis layouts
- https://developer.android.com/develop/adaptive-apps/guides/flexbox/get-started — FlexBox setup (`foundation-layout` dependency) and first layouts
- https://developer.android.com/develop/adaptive-apps/guides/flexbox/container-behavior — `FlexBoxConfig` properties: `direction`, `wrap`, `justifyContent`, `alignItems`, `alignContent`, `gap`
- https://developer.android.com/develop/adaptive-apps/guides/flexbox/item-behavior — `Modifier.flex` properties: `basis`, `grow`, `shrink`, `alignSelf`, `order`
- https://developer.android.com/develop/ui/compose/layouts/custom — official custom-layout guide; use when built-in containers are not enough
- `Layout fundamentals` in the Compose docs covers the base mental model for `Box`, `Row`, `Column`, `Spacer`, modifiers, and constraints
- `Layout containers` and `Advanced and custom layouts` in the same doc tree cover lazy lists/grids, flow layouts, custom layouts, alignment lines, and intrinsic measurement
---
## FlexBox — experimental CSS-flexbox-equivalent container
`FlexBox` (`androidx.compose.foundation.layout`, `@ExperimentalFlexBoxApi`) is a single-axis
container that wraps, grows, and shrinks items to fill available space — modeled directly on
the [CSS Flexible Box spec](https://www.w3.org/TR/css-flexbox-1/). It is a **layout primitive**,
not a `WindowSizeClass` replacement: use it *inside* a breakpoint branch (or any composable) when
a row/column of items needs to reflow, not to decide the breakpoint itself.
**When to reach for it:**
- A small-to-medium set of items (chips, tags, action buttons, badges) that should wrap onto
new lines when space runs out
- Items with different grow/shrink weights on the same axis — cheaper than a manual
`Row` + `weight()` + conditional wrap logic
- **Not for:** large/lazy datasets (use `LazyRow`/`LazyColumn`/`LazyGrid`) or overall screen
structure (use the `WindowSizeClass` patterns above, or Grid)
### Setup
```toml
# libs.versions.toml
[versions]
compose = "1.12.0-beta02"
[libraries]
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "compose" }
```
### Container config (`FlexBoxConfig`)
```kotlin
@ExperimentalFlexBoxApi
FlexBox(
config = {
direction(FlexDirection.Row) // Row / RowReverse / Column / ColumnReverse
wrap(FlexWrap.Wrap) // NoWrap (default) / Wrap / WrapReverse
justifyContent(FlexJustifyContent.Center) // main-axis distribution
alignItems(FlexAlignItems.Center) // cross-axis alignment (single line)
alignContent(FlexAlignContent.SpaceAround) // cross-axis alignment (multiple lines, wrap only)
gap(8.dp) // shorthand for rowGap + columnGap
}
) {
// items
}
```
### Item config (`Modifier.flex`)
```kotlin
GreenRoundedBox(
modifier = Modifier.flex {
basis(FlexBasis.Auto) // Auto | fixed dp | 0f–1f percentage of container
grow(1.0f) // share of extra main-axis space
shrink(1f) // share of space deficit; 0f = never shrink
alignSelf(FlexAlignSelf.Center) // overrides container's alignItems for this item
order(-1) // visual order only; declaration order unchanged
}
)
```
- `grow`/`shrink` are proportional, like CSS: an item with `grow(2f)` takes twice the extra
space of a sibling with `grow(1f)`.
- `basis` sets the pre-distribution size; if it's smaller than the item's intrinsic minimum,
the intrinsic minimum wins.
- Still experimental — annotate call sites with `@OptIn(ExperimentalFlexBoxApi::class)` and
recheck the API shape when bumping the Compose Foundation version, same as any other
`@Experimental*` surface in this repo.
---
## Recommendation First
Calculate `WindowSizeClass` **once** at the app root and pass it down as a parameter.
Never call `calculateWindowSizeClass()` inside a leaf composable — it re-reads window
metrics on every recomposition and makes components impossible to preview or test in
isolation.
**Why:**
- A single read at the root means every screen snapshot test can supply a fake
`WindowSizeClass` — no device, no emulator
- Passing as a parameter makes the layout decision visible in the call site — you can
read a composable signature and immediately know it is adaptive
- Centralising the calculation prevents two screens from disagreeing on the breakpoint
because they each read at a different recomposition moment
---
## Cross-session pattern consistency
Before implementing a new screen, run:
```bash
grep -r "WindowSizeClass\|calculateWindowSizeClass\|WindowWidthSizeClass" \
<project_root>/*/src --include="*.kt" -l
```
If any file matches, the adaptive pattern is **already established**. Read one of those
files and replicate the exact same:
- `WindowSizeClass` parameter name and position in the screen signature
- Breakpoint switch structure (`when (windowSizeClass.widthSizeClass)`)
- Helper layout composable naming (`FooContentCompact`, `FooContentMedium`,
`FooContentExpanded`)
Never introduce a second pattern in the same project.
If nothing matches, establish the pattern using the template below and update
`.claude/pipeline-context.json`:
```json
"adaptive_layout_established": true,
"adaptive_layout_root_file": "<path to the first screen that established the pattern>"
```
### Retrofitting an existing project
If the project already has many screens **without** `WindowSizeClass`, retrofitting all
of them in one session is impractical. Use migration mode to avoid being blocked:
1. Set `adaptive_layout_migration_mode: true` in `.claude/pipeline-context.json` and commit it
2. The reviewer will warn (not block) on pre-existing screens and only enforce the full
rule on screens created or modified in the current session
3. Track the remaining screens as a follow-up ticket
4. Once all screens are migrated, set `adaptive_layout_migration_mode: false` and commit
---
## Core pattern — screen structure
```kotlin
// FooScreen.kt (in :ui)
@Composable
fun FooScreen(
windowSizeClass: WindowSizeClass, // passed from nav host — never calculated here
viewModel: FooViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
FooContent(
state = state,
windowSizeClass = windowSizeClass,
onIntent = viewModel::onIntent
)
}
@Composable
fun FooContent(
state: FooContract.State,
windowSizeClass: WindowSizeClass,
onIntent: (FooContract.Intent) -> Unit
) {
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> FooContentCompact(state, onIntent)
WindowWidthSizeClass.Medium -> FooContentMedium(state, onIntent)
WindowWidthSizeClass.Expanded -> FooContentExpanded(state, onIntent)
else -> FooContentCompact(state, onIntent)
}
}
```
---
## List-detail split (Expanded)
```kotlin
@Composable
fun FooContentExpanded(
state: FooContract.State,
onIntent: (FooContract.Intent) -> Unit
) {
Row(modifier = Modifier.fillMaxSize()) {
FooList(
items = state.items,
selectedId = state.selectedId,
onSelect = { onIntent(FooContract.Intent.SelectItem(it)) },
modifier = Modifier
.weight(1f)
.fillMaxHeight()
)
VerticalDivider()
FooDetail(
item = state.selectedItem,
modifier = Modifier
.weight(2f)
.fillMaxHeight()
)
}
}
@Composable
fun FooContentMedium(
state: FooContract.State,
onIntent: (FooContract.Intent) -> Unit
) {
// Medium: show list; tapping an item opens detail as a pane or bottom sheet
Row(modifier = Modifier.fillMaxSize()) {
FooList(
items = state.items,
selectedId = state.selectedId,
onSelect = { onIntent(FooContract.Intent.SelectItem(it)) },
modifier = Modifier.weight(1f)
)
state.selectedItem?.let { item ->
FooDetail(item = item, modifier = Modifier.weight(1f))
}
}
}
```
---
## Adaptive navigation
```kotlin
@Composable
fun AdaptiveNavScaffold(
windowSizeClass: WindowSizeClass,
destinations: List<TopLevelDestination>,
currentDestination: NavDestination?,
onNavigate: (TopLevelDestination) -> Unit,
content: @Composable (PaddingValues) -> Unit
) {
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> Scaffold(
bottomBar = {
AppBottomBar(destinations, currentDestination, onNavigate)
},
content = content
)
WindowWidthSizeClass.Medium -> Row(Modifier.fillMaxSize()) {
AppNavigationRail(destinations, currentDestination, onNavigate)
Scaffold(content = content)
}
WindowWidthSizeClass.Expanded -> Row(Modifier.fillMaxSize()) {
AppPermanentDrawer(destinations, currentDestination, onNavigate)
Scaffold(content = content)
}
else -> Scaffold(
bottomBar = { AppBottomBar(destinations, currentDestination, onNavigate) },
content = content
)
}
}
```
---
## Passing WindowSizeClass from the NavHost
```kotlin
// App.kt — the single calculation point
@Composable
fun App() {
val windowSizeClass = calculateWindowSizeClass()
AppTheme(darkTheme = isSystemInDarkTheme()) {
AdaptiveNavScaffold(windowSizeClass = windowSizeClass, ...) {
NavHost(...) {
composable<HomeRoute> {
HomeScreen(windowSizeClass = windowSizeClass)
}
composable<ProfileRoute> {
ProfileScreen(windowSizeClass = windowSizeClass)
}
}
}
}
}
```
---
## Roborazzi tests — required for every adaptive screen
Every screen with adaptive layout **must** have Roborazzi tests for all three breakpoints
and both themes. Minimum required captures per screen:
```kotlin
@OptIn(ExperimentalTestApi::class)
class FooContentScreenshotTest {
private val compactWindowSize = WindowSizeClass.calculateFromSize(DpSize(360.dp, 800.dp))
private val mediumWindowSize = WindowSizeClass.calculateFromSize(DpSize(700.dp, 800.dp))
private val expandedWindowSize = WindowSizeClass.calculateFromSize(DpSize(1280.dp, 900.dp))
private val defaultState = FooContract.State(/* default */)
@Test fun foo_compact_light() {
captureRoboImage("foo_compact_light.png") {
AppTheme(darkTheme = false) {
FooContent(defaultState, compactWindowSize, {})
}
}
}
@Test fun foo_compact_dark() {
captureRoboImage("foo_compact_dark.png") {
AppTheme(darkTheme = true) {
FooContent(defaultState, compactWindowSize, {})
}
}
}
@Test fun foo_expanded_light() {
captureRoboImage("foo_expanded_light.png") {
AppTheme(darkTheme = false) {
FooContent(defaultState, expandedWindowSize, {})
}
}
}
@Test fun foo_expanded_dark() {
captureRoboImage("foo_expanded_dark.png") {
AppTheme(darkTheme = true) {
FooContent(defaultState, expandedWindowSize, {})
}
}
}
}
```
Medium is optional if it renders identically to Compact or Expanded. Compact + Expanded
is the required minimum.
---
## Common Anti-Patterns
- **`calculateWindowSizeClass()` inside a leaf composable** — re-reads window metrics on
every recomposition and cannot be overridden in tests; always call once at the app root
- **Hardcoded dp breakpoints** — `if (screenWidth > 600.dp)` instead of
`WindowWidthSizeClass.Medium`; magic numbers diverge from the system breakpoints and
break on unusual screen sizes
- **`LocalConfiguration.current.screenWidthDp` for breakpoints** — not multiplatform;
unavailable on Desktop and iOS; use `WindowSizeClass` throughout
- **Only testing Compact** — medium and expanded layouts diverge significantly; a
Roborazzi test for Expanded catches list-detail regressions that phone-only tests miss
- **No dark mode variant for adaptive screenshots** — a muted text color readable in
light mode on a white list-detail pane becomes invisible in dark mode; capture both
- **Inconsistent pattern across screens** — one screen uses `WindowSizeClass`, another
uses hardcoded dp checks; always grep for existing pattern before implementing a new screen
---
## Related Skills
- `kmp-compose-design-system` — `AppTheme` drives the dark/light mode toggle
that adaptive screenshots depend on; tokens must have both light and dark variants
- `kmp-navigation` — the nav host is where `WindowSizeClass` is
distributed to each screen route
- `kmp-roborazzi` — required for capturing adaptive layout goldens;
`WindowSizeClass.calculateFromSize(DpSize(...))` is the test-time substitute for a real device
- `kmp-mvi` — `FooContent` is a pure MVI content composable; the
adaptive switch lives inside it, not in the ViewModel
---
## Output Style
When implementing adaptive layout, respond in this order:
1. **Grep result** — confirm whether adaptive pattern already exists in the project
2. **Breakpoint strategy** — which layout differences exist at each breakpoint
3. **Screen structure** — `FooScreen` + `FooContent` + three layout composables
4. **Navigation** — whether `AdaptiveNavScaffold` needs updating for the new screen
5. **Roborazzi tests** — compact+dark, compact+light, expanded+dark, expanded+light
6. **Pipeline-context update** — set `adaptive_layout_established: true` if this is the first
---
## Changelog
| Date | Change |
|---|---|
| 2026-07-07 | Added the FlexBox reference section (`@ExperimentalFlexBoxApi`, `FlexBoxConfig`, `Modifier.flex`) with setup, container/item property tables, and links to the four official adaptive-apps FlexBox guides — for wrapping/growing item layouts used inside a breakpoint branch, not as a WindowSizeClass replacement. |
| 2026-06-21 | Initial release. |
| 2026-06-26 | Added official Compose layouts overview to the skill’s reference layer and called out the layout fundamentals / layout containers / custom layouts doc family for base container and constraint guidance. |
| 2026-06-26 | Added the official adaptive apps getting-started page and the Compose custom layouts guide as direct reference links for adaptive and custom layout work. |
| 2026-06-26 | Added canonical adaptive layouts and adaptive navigation reference pages so the skill now points to the full official adaptive layout path. |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!