Layouts, Material 3, Adaptive UI, Popups and Accessibility
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Once the screens of an app live in shared code, the questions change from "how do I call the platform?" to "how does one composable tree look and behave properly on a phone, a tablet, a desktop window and a browser tab, for users with and without assistive technology?". This page answers that for the visual layer of Compose Multiplatform (CMP): layouts and modifiers, Material 3, adaptive layouts driven by window size classes, popups and dialogs, drag and drop, accessibility and images.
The examples target the section baseline: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4 (shared modules
use com.android.kotlin.multiplatform.library) and Xcode 26.4, next to Ktor 3.6.0, Koin 4.2.2, SKIE 0.10.15,
KMP-NativeCoroutines 1.0.6, SQLDelight 2.4.0, Room 3 (androidx.room3) 3.0.3, Mokkery 3.5.0, vanniktech 0.37.0,
Dokka 2.2.0 and Turbine 1.2.1. Swift export and SwiftPM import are Alpha; Kotlin/Wasm and CMP for web are
Beta. Stability is stated wherever an API is not Stable: MaterialExpressiveTheme and the drag-and-drop modifiers
are Experimental.
Jetpack Compose and SwiftUI are not re-taught. Because CMP is a port of Jetpack Compose, everything in
Compose Layouts, Lists and Modifiers,
Compose Material 3 and Theming and
Accessibility on Android carries over; this page covers what is different or
platform-sensitive when the code is shared. The Kotlin language is linked from
the Kotlin section. The examples slice TrailMate Everywhere
(package com.example.trailmate), introduced in
What Kotlin Multiplatform Is and When to Use It; the
foundations of CMP (artifacts, entry points, previews) are in
Compose Multiplatform Fundamentals, resources
in Resources and Localization in Compose Multiplatform,
and the iOS host in Compose Multiplatform on iOS.
What Is Shared and What Stays Platform-Specific
CMP renders its own pixels with Skia on every target, so a Column is positioned identically on Android, iOS,
desktop and web. That is the value proposition: one layout system and one design system. The price is that the
platform’s native look and behavior (system controls, VoiceOver traversal, window chrome) are reproduced or bridged
by the framework rather than inherited for free.
| Concern | Shared in commonMain |
Needs platform awareness |
|---|---|---|
Layout and modifiers |
|
Insets (safe area on iOS, system bars on Android), keyboard (IME) behavior, scroll physics |
Theming |
|
Dynamic color exists only on Android 12+; iOS has no Material You, so use a static scheme |
Adaptive UI |
|
Foldable posture and device-fold APIs are Android-centric; desktop and web resize freely |
Popups and dialogs |
|
Back handling (Android back button, desktop Esc), iOS safe-area clipping, desktop |
Drag and drop |
|
The transferred data is wrapped in a platform type ( |
Accessibility |
|
Screen reader (TalkBack, VoiceOver, macOS VoiceOver, JAWS/NVDA, browser readers) and contrast settings |
Images and icons |
|
Image decoding formats and network image loaders |
Layouts and Modifiers in Common Code
The layout primitives are exactly those of Jetpack Compose and live in the artifacts org.jetbrains.compose.foundation
and org.jetbrains.compose.ui. A composable in sharedUI therefore needs no expect/actual for layout at all. The
one adjustment is that anything from android.* (for example LocalContext, Configuration, stringResource(R.string…))
must be replaced by its multiplatform counterpart, otherwise the file stops compiling for the non-Android targets.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/TrailCard.kt
package com.example.trailmate.ui.list
// Routine androidx.compose.runtime, foundation.layout and ui.unit imports are omitted in this page's snippets
import androidx.compose.foundation.clickable
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.text.style.TextOverflow
import com.example.trailmate.domain.Trail
@Composable
fun TrailCard(trail: Trail, onClick: () -> Unit, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(Modifier.weight(1f)) {
Text(trail.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${trail.region} - ${trail.lengthKm} km", style = MaterialTheme.typography.bodyMedium)
}
Text(trail.difficulty.name, style = MaterialTheme.typography.labelLarge)
}
}
}
The signature follows the Compose convention of an optional modifier parameter placed first among the optional
parameters, so callers (the list screen, a tablet pane) can size and position the card. See
Compose Layouts, Lists and Modifiers for the reasoning.
Modifiers Behave the Same, with a Few Platform Exceptions
Modifiers are an ordered chain: padding before background is not the same as background before padding. That
rule is identical everywhere. What differs by platform is the input model that some modifiers expose.
| Modifier | Common behavior | Platform nuance |
|---|---|---|
|
Click with ripple and semantics role |
On desktop and web it also reacts to mouse hover and the keyboard (Enter/Space) when focused |
|
Observe pointer hover and set the cursor |
Meaningful on desktop and web with a mouse; touch devices never hover |
|
Handle hardware keyboard events |
Needed for shortcuts on desktop; iOS only with an external keyboard |
|
Apply system insets as padding |
Android and iOS report real insets; desktop and web report zero |
|
Drag to scroll with fling |
Fling physics and overscroll differ per platform (rubber-banding on iOS) |
|
Cross-app drag and drop |
Experimental; see Drag and Drop (Experimental) |
Insets and Edge-to-Edge
Android 15 enforces edge-to-edge drawing, and iOS draws under the status bar and home indicator by default. In shared
code, Scaffold consumes the insets and passes them as PaddingValues to its content. If you place content directly
in a Box you must call Modifier.safeDrawingPadding() yourself. Edge-to-edge details for Android are in
Adaptive Layouts, Edge-to-Edge and Large Screens.
Common mistake: applying innerPadding to a Box that wraps a LazyColumn and then padding the list again, which
doubles the top inset on iOS. Pass innerPadding once, either as Modifier.padding or inside contentPadding.
Material 3 in Compose Multiplatform
Material 3 (androidx.compose.material3) is published for all CMP targets as org.jetbrains.compose.material3:material3.
Its version is decoupled from the Compose Multiplatform plugin version: the plugin pins a stable default, but you
may depend on a newer (even pre-release) Material 3 build without upgrading CMP. The same holds for the adaptive
libraries under org.jetbrains.compose.material3.adaptive. The mapping from CMP release to underlying Jetpack
versions is listed in each release’s "What’s new" page, so check it before mixing versions.
# gradle/libs.versions.toml
[versions]
compose-multiplatform = "1.12.1"
# Material 3 and Material 3 Adaptive are versioned independently (values listed in the CMP 1.12.1 release notes)
material3 = "1.12.0-alpha03"
material3-adaptive = "1.3.0-rc01"
[libraries]
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "material3-adaptive" }
[plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version = "2.4.20" }
See Kotlin docs — What’s new in Compose Multiplatform 1.9.3 for the first release that decoupled the Material 3 version.
The adaptive libraries follow their own version line, so the version above is a placeholder rather than a literal:
the release notes list the exact adaptive version matching your CMP release. Using the plugin accessor
compose.material3 is still possible and pins the default Material 3 version.
A Shared Theme with Light, Dark and Dynamic Color
The theme is the single place where color, type and shape are decided. TrailMateTheme picks a scheme from the
system dark-mode setting (isSystemInDarkTheme() works in common code) and optionally from a platform-provided
dynamic scheme. Dynamic color exists only on Android 12 and later, so it is an expect/actual hook with a null
fallback; see expect/actual and Platform APIs.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/theme/TrailMateTheme.kt
package com.example.trailmate.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
private val LightColors = lightColorScheme(primary = Color(0xFF2E7D32), onPrimary = Color.White)
private val DarkColors = darkColorScheme(primary = Color(0xFF81C784), onPrimary = Color(0xFF0B2D0E))
@Composable
expect fun dynamicColorScheme(darkTheme: Boolean): ColorScheme?
@Composable
fun TrailMateTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
useDynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colors = (if (useDynamicColor) dynamicColorScheme(darkTheme) else null)
?: if (darkTheme) DarkColors else LightColors
MaterialTheme(colorScheme = colors, content = content)
}
// sharedUI/src/androidMain/kotlin/com/example/trailmate/ui/theme/DynamicColor.android.kt
// (iOS, desktop and web actuals simply return null)
@Composable
actual fun dynamicColorScheme(darkTheme: Boolean): ColorScheme? =
if (android.os.Build.VERSION.SDK_INT >= 31) {
val context = androidx.compose.ui.platform.LocalContext.current
if (darkTheme) androidx.compose.material3.dynamicDarkColorScheme(context)
else androidx.compose.material3.dynamicLightColorScheme(context)
} else null
See Kotlin docs — Layouts in Compose Multiplatform and the Android counterpart in Compose Material 3 and Theming.
Common mistakes: reading LocalContext in commonMain (Android only); forgetting that isSystemInDarkTheme() on
iOS tracks the user interface style of the hosting UIViewController, so an app forced to light mode in Info.plist
will never report dark.
MaterialExpressiveTheme (Experimental)
Material 3 Expressive adds a motion scheme, richer shapes and new components. In CMP it is exposed through
MaterialExpressiveTheme, which takes colorScheme, motionScheme, shapes and typography. It is
Experimental: it requires @OptIn(ExperimentalMaterial3ExpressiveApi::class), and the experimental symbols are
only in the alpha Material 3 builds, because the stable Material 3 line removed them. Use it only if you accept API
changes between releases.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/theme/ExpressiveTrailMateTheme.kt
package com.example.trailmate.ui.theme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.lightColorScheme
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ExpressiveTrailMateTheme(content: @Composable () -> Unit) {
MaterialExpressiveTheme(
colorScheme = lightColorScheme(),
motionScheme = MotionScheme.expressive(),
content = content,
)
}
| Theme entry point | Stability | Use when |
|---|---|---|
|
Stable |
Production apps; works with every Material 3 version |
|
Experimental (alpha Material 3 build, opt-in required) |
Prototypes and design experiments that follow Material 3 Expressive |
Custom design system over |
Stable |
Brand-specific look with no Material components (see CMP’s non-Material option) |
Adaptive UI with Window Size Classes
An adaptive app changes its layout with the space it gets, not with the device model. On Android that space can
change at runtime (split screen, foldables, freeform windows); on iOS it changes with iPad multitasking and Stage
Manager; on desktop and web the user drags the window edge. CMP exposes the Jetpack window size class model in common
code through the window-core library and the Material 3 adaptive libraries.
Window Size Classes
A window size class buckets the available width and height into compact, medium and expanded (newer versions add
larger breakpoints). The recommended entry point is currentWindowAdaptiveInfoV2(), which supersedes the earlier
currentWindowAdaptiveInfo() and returns a WindowAdaptiveInfo whose windowSizeClass you query with the
isWidthAtLeastBreakpoint and isHeightAtLeastBreakpoint functions rather than comparing enum values.
| Width bucket | Lower bound | Typical device |
|---|---|---|
Compact |
0 dp |
Phone in portrait |
Medium |
600 dp |
Small tablet, large phone in landscape, narrow desktop window |
Expanded |
840 dp |
Tablet landscape, desktop and web window |
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/adaptive/TrailMateApp.kt
package com.example.trailmate.ui.adaptive
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.window.core.layout.WindowSizeClass
enum class TrailLayout { Compact, TwoPane }
@Composable
fun rememberTrailLayout(): TrailLayout {
val sizeClass = currentWindowAdaptiveInfoV2().windowSizeClass
return if (sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) {
TrailLayout.TwoPane
} else {
TrailLayout.Compact
}
}
Depend on org.jetbrains.compose.material3.adaptive:adaptive in commonMain (see the Gradle snippet above). Keep the
decision in one function like rememberTrailLayout() so the thresholds are not scattered across screens, and so tests
can substitute a fixed layout.
Choosing the Layout from the Class
The simplest adaptive pattern is a when over the layout bucket. On a compact window TrailListScreen navigates to
TrailDetailScreen; on an expanded window both appear side by side and selection changes only the right pane.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/adaptive/TrailListDetail.kt
package com.example.trailmate.ui.adaptive
import com.example.trailmate.domain.Trail
import com.example.trailmate.ui.list.TrailListContent
@Composable
fun TrailListDetail(trails: List<Trail>) {
var selectedId by rememberSaveable { mutableStateOf<String?>(null) }
when (rememberTrailLayout()) {
TrailLayout.Compact ->
if (selectedId == null) TrailListContent(trails) { selectedId = it }
else TrailDetailPane(trails.first { it.id == selectedId }) { selectedId = null }
TrailLayout.TwoPane -> Row {
TrailListContent(trails, onTrailClick = { selectedId = it }) // list on the left
selectedId?.let { id -> TrailDetailPane(trails.first { it.id == id }, onBack = null) }
?: Text("Select a trail")
}
}
}
The state is kept with rememberSaveable, so rotating or resizing does not lose the selection. The shared
navigation setup that decides which screen is on top is covered in
Lifecycle, ViewModel and Navigation in Compose Multiplatform.
Canonical Layouts: the Material 3 Adaptive Scaffolds
For list-detail, supporting-pane and navigation chrome, the Material 3 adaptive libraries ship ready-made scaffolds that handle pane count, spacing and (on Android) fold posture:
| Artifact / API | Purpose | Maturity |
|---|---|---|
|
Window size class and posture information |
Follows the adaptive library line; check the release notes |
|
Panes shown one, two or three at a time, with a navigator |
Material 3 adaptive APIs; several remain annotated experimental, so expect opt-ins |
|
Bottom bar, rail or drawer chosen from the size class |
Same as above |
Because stability of each scaffold changes between releases, read the API annotations in your Material 3 adaptive version rather than relying on this table. The concepts are explained for Android in Adaptive Layouts, Edge-to-Edge and Large Screens.
NavigationSuiteScaffold shows a bottom bar on a compact window, a navigation rail on medium and a drawer on large,
without any expect/actual. A NavigationSuiteScaffold(navigationSuiteItems = \{ item(selected, onClick, icon, label) }) \{ content } call is all that is needed; the selected destination is kept with rememberSaveable.
Adaptive Gotchas per Target
-
iOS: iPhone size classes mostly stay compact; iPad windows can be resized in Stage Manager, so the adaptive code does run. Test with the iPad simulator in both orientations.
-
Desktop: set a sensible minimum window size, otherwise users can shrink the window below your compact layout.
-
Web: the browser viewport is the window;
ComposeViewportreports its size changes, so layouts follow the tab. -
Android: configuration changes are handled by Compose; do not also react to
onConfigurationChangedfor layout.
Popup and Dialog
CMP distinguishes two overlay containers. Choosing the wrong one is the most common mistake.
| Aspect | Dialog |
Popup |
|---|---|---|
Modality |
Modal: takes focus, dims the content with a scrim and blocks the UI behind it |
Non-modal: no scrim, the UI underneath stays interactive |
Position |
Centered |
Alignment plus offset, or a |
Width |
Constrained to a dialog width by default |
Unrestricted |
Use for |
Confirmations, alerts, short forms where the user must decide |
Dropdowns, tooltips, menus, hint overlays |
Properties class |
|
|
See Kotlin docs — Popups.
Popup: Alignment, Offset and Anchors
A Popup is declared in the composition where its anchor lives and is shown only while a state flag is true. With
alignment and offset it is positioned relative to its parent; for anchored placement implement
PopupPositionProvider, which receives the anchor bounds, window size, layout direction and the popup’s content size
and returns the top-left IntOffset.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/DifficultyFilterPopup.kt
package com.example.trailmate.ui.list
import androidx.compose.material3.Button
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
@Composable
fun DifficultyFilterPopup() {
var open by remember { mutableStateOf(false) }
val belowAnchor = remember {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
) = IntOffset(x = anchorBounds.left, y = anchorBounds.bottom)
}
}
Box(Modifier.padding(16.dp)) {
Button(onClick = { open = !open }) { Text("Difficulty") }
if (open) {
Popup(
popupPositionProvider = belowAnchor,
onDismissRequest = { open = false },
properties = PopupProperties(focusable = true, dismissOnClickOutside = true),
) {
Box(Modifier.background(Color.White, RoundedCornerShape(8.dp)).padding(12.dp)) {
Text("Easy - Moderate - Hard")
}
}
}
}
}
See Kotlin docs — Popups.
PopupProperties and DialogProperties
| Property | Meaning | Default |
|---|---|---|
|
Whether the popup receives key events |
|
|
Dismiss on Android back or the Esc key on desktop; requires |
|
|
Dismiss when the user presses outside the popup |
|
|
iOS-only: clamp the content to the platform insets (safe area) |
platform-specific |
|
Same intent for dialogs |
|
|
Use the platform dialog width instead of full content width |
|
Properties that are not in the common source set (for example usePlatformInsets) are reachable only from the
platform source set, or through an expect/actual wrapper.
Dialog
Dialog takes onDismissRequest, optional DialogProperties and its content; Material’s AlertDialog is a styled
wrapper. A Dialog always stays inside the host window on mobile and web. On desktop, where the user may want an
OS-level window that can move outside the main window, CMP offers DialogWindow (covered in
Compose Multiplatform for Desktop).
A typical call is AlertDialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnClickOutside = false), title = \{ … }, confirmButton = \{ … }) in sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/detail/; dismissOnClickOutside = false forces an explicit choice for destructive actions.
See Kotlin docs — Popups.
Common mistakes: using Dialog for a dropdown (it blocks the UI and dims it); forgetting to reset the state flag in
onDismissRequest, so the popup reappears on the next recomposition; and expecting the Android back button to dismiss
a Popup without focusable = true.
Drag and Drop (Experimental)
CMP lets a composable be a source of dragged data, a target that accepts drops, or both, including drags between
your app and other apps. The two modifiers are Modifier.dragAndDropSource and Modifier.dragAndDropTarget. Both are
Experimental: they are subject to change and require an opt-in annotation. The modifiers are common API, but the
payload is wrapped in a platform type.
| Platform | Payload wrapped in |
|---|---|
Android |
|
iOS |
|
Desktop |
AWT |
Web |
|
A drop target is a remembered DragAndDropTarget object overriding onStarted, onEnded and onDrop; the modifier’s
shouldStartDragAndDrop decides whether the target takes part in a given session. The example reorders nothing; it
accepts a trail name dragged from another app on desktop and prints the action, which is the safest form of the API
to start with.
// desktopApp/src/jvmMain/kotlin/com/example/trailmate/desktop/TrailDropTarget.kt
package com.example.trailmate.desktop
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draganddrop.awtTransferable
import androidx.compose.ui.draganddrop.dragAndDropTarget
import java.awt.datatransfer.DataFlavor
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
fun TrailNameDropZone(onTrailNameDropped: (String) -> Unit) {
var highlighted by remember { mutableStateOf(false) }
val target = remember {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) { highlighted = true }
override fun onEnded(event: DragAndDropEvent) { highlighted = false }
override fun onDrop(event: DragAndDropEvent): Boolean {
val transferable = event.awtTransferable
if (!transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) return false
onTrailNameDropped(transferable.getTransferData(DataFlavor.stringFlavor) as String)
return true
}
}
}
Box(
Modifier.size(200.dp)
.background(if (highlighted) Color.LightGray else Color.White)
.dragAndDropTarget(shouldStartDragAndDrop = { true }, target = target),
) { Text("Drop a trail name here") }
}
A drag source returns a DragAndDropTransferData describing the data, the supportedActions (copy, move, link) and an
onTransferCompleted callback; an optional drawDragDecoration lambda draws the dragged preview. Because both the
opt-in annotations and the payload types differ per platform, keep drag and drop behind an interface in
commonMain and implement it per source set, or confine it to the platforms that need it (desktop and web are the
common cases). Do not build a core feature on it until it graduates from Experimental.
Accessibility
Accessibility in CMP is built on semantics: every composable can contribute properties to a semantic tree, a simplified view of the UI that assistive technologies traverse instead of the pixel tree. CMP supports the semantic model of Jetpack Compose in common code and bridges it to each platform’s accessibility service. The framework is documented as enabling apps that meet the European Accessibility Act and WCAG, but compliance still depends on how you write semantics and choose colors.
Common Semantics
| Property | Meaning | Example in TrailMate |
|---|---|---|
|
Text a screen reader announces for non-textual or ambiguous elements |
A favorite |
|
Functional type: |
A custom clickable card exposed as a button |
|
The current state of an interactive element |
"Favorite" or "Not favorite" |
|
Stable identifier for UI tests and automation |
|
|
Reading order for complex layouts; lower index is read first |
Reading a floating action button before the list |
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/FavoriteButton.kt
package com.example.trailmate.ui.list
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
@Composable
fun FavoriteButton(trailId: String, trailName: String, isFavorite: Boolean, onToggle: () -> Unit) {
IconButton(
onClick = onToggle,
modifier = Modifier
.testTag("favorite_$trailId")
.semantics {
role = Role.Button
stateDescription = if (isFavorite) "Favorite" else "Not favorite"
},
) {
Icon(
imageVector = favoriteIcon(isFavorite),
contentDescription = "Toggle $trailName as favorite", // announced by every screen reader
)
}
}
Reading order is controlled with isTraversalGroup = true and traversalIndex inside Modifier.semantics; the
default index is 0f, and a lower value is read earlier. Use it sparingly: the default left-to-right,
top-to-bottom order is right for most screens, and a wrong manual order is worse than none.
iOS: VoiceOver Mapping
On iOS, CMP converts the semantic tree into native accessibility objects, so VoiceOver and Switch Control can read
and operate the Compose UI with the same gestures as a native app. For most Material widgets this is automatic.
The mapping extends to automation: testTag maps to accessibilityIdentifier, so XCUITest can find a CMP element
exactly as it finds a SwiftUI one.
// iosApp/iosAppUITests/FavoriteUITests.swift
import XCTest
final class FavoriteUITests: XCTestCase {
func testToggleFavorite() {
let app = XCUIApplication()
app.launch()
// "favorite_42" is the Compose testTag, exposed as accessibilityIdentifier
let button = app.buttons["favorite_42"]
XCTAssertTrue(button.waitForExistence(timeout: 5))
button.tap()
XCTAssertEqual(button.value as? String, "Favorite")
}
}
The page also documents support for AssistiveTouch (pointer control) and Full Keyboard Access, and for a
high-contrast theme: Material 3’s ColorScheme has no built-in high-contrast variant, so you supply an extra palette
and switch to it when UIAccessibilityDarkerSystemColorsEnabled is true, observing
UIAccessibilityDarkerSystemColorsStatusDidChangeNotification. Keep text contrast at least 4.5:1 (7:1 is recommended
for small text). How SwiftUI views and CMP screens coexist in one app is covered in
Compose Multiplatform on iOS; the platform’s own
accessibility model is in Accessibility on Apple Platforms.
Desktop: macOS and Windows
| Platform | Accessibility status |
|---|---|
macOS |
Fully supported through the native accessibility API |
Windows |
Supported through Java Access Bridge, which is disabled by default |
Linux |
Not supported |
On Windows, enable the bridge while developing with %JAVA_HOME%\bin\jabswitch.exe /enable, and include the
jdk.accessibility module in packaged apps so the shipped build exposes accessibility information.
// desktopApp/build.gradle.kts
compose.desktop {
application {
mainClass = "com.example.trailmate.desktop.MainKt"
nativeDistributions {
// Required for screen readers on Windows (Java Access Bridge)
modules("jdk.accessibility")
}
}
}
Test on macOS with Xcode’s Accessibility Inspector, and on Windows with the speech history in JAWS or the Speech Viewer in NVDA. Packaging details are in Compose Multiplatform for Desktop.
Web
CMP for web (Beta) exposes the semantic tree to the browser’s accessibility tree, so screen readers can reach the
canvas-rendered UI. Because the page is drawn to a canvas, native browser features such as text search, link
previews and translation behave differently from a DOM page. Always provide contentDescription for images, set a
meaningful <title> in the host HTML and test with a real screen reader (VoiceOver in Safari, NVDA in Firefox).
Hosting and deployment are covered in Web Targets: Kotlin/Wasm, Kotlin/JS and Compose for Web.
Accessibility Mistakes to Avoid
-
contentDescription = nullon an icon that is the only content of a clickable element; the control becomes unlabeled. -
Describing the visual ("green arrow icon") instead of the action ("Open trail details").
-
Clickable
Boxwithoutrole, which is announced as plain text on VoiceOver. -
Assuming Material colors meet contrast needs after customizing the scheme; check it, especially in dark mode.
-
Touch targets below 48 dp; Material components enforce a minimum, custom ones do not.
Images and Icons
Common code has three sources of visuals, each with a different trade-off.
| Source | API | Notes |
|---|---|---|
Bundled resources |
|
Vector (XML) and bitmap (PNG, JPEG, WebP) in |
Material icons |
|
Only the core set ships with CMP; the extended set is large and often replaced by custom vectors |
Remote images |
A multiplatform loader such as Coil 3 ( |
Not part of CMP itself; covers |
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/TrailImage.kt
package com.example.trailmate.ui.list
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.Icon
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import org.jetbrains.compose.resources.painterResource
import trailmate.sharedui.generated.resources.Res
import trailmate.sharedui.generated.resources.trail_placeholder
fun favoriteIcon(isFavorite: Boolean): ImageVector =
if (isFavorite) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder
@Composable
fun TrailPlaceholder() {
Image(
painter = painterResource(Res.drawable.trail_placeholder),
contentDescription = "Trail photo placeholder",
modifier = Modifier.fillMaxWidth().height(160.dp),
contentScale = ContentScale.Crop,
)
}
Add material-icons-core explicitly in commonMain if the icons do not resolve. Vector drawables keep the app small
and scale across densities; prefer them for icons and use bitmaps for photographs. Decorative images should pass
contentDescription = null so screen readers skip them, while informative ones need a real description. Resource
generation, qualifiers and localization are in
Resources and Localization in Compose Multiplatform.
Common Mistakes
-
Using
Dialogwhere a non-modalPopupis meant, or the reverse. -
Reading the width class once with
remember, so layouts do not update when the window resizes; callcurrentWindowAdaptiveInfoV2()in composition instead. -
Mixing Material 3 and CMP versions without checking the release notes mapping.
-
Adopting
MaterialExpressiveThemeor drag and drop in a long-lived app without noting they are Experimental. -
Omitting
jdk.accessibilityfrom the Windows distribution, then discovering screen readers see nothing. -
Testing accessibility only on Android and assuming iOS and desktop behave the same.
-
Treating insets as zero on desktop and web code paths and forgetting them on iOS.