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

Row, Column, Box, LazyColumn, Modifier.padding, weight, fillMaxWidth

Insets (safe area on iOS, system bars on Android), keyboard (IME) behavior, scroll physics

Theming

MaterialTheme, ColorScheme, Typography, Shapes

Dynamic color exists only on Android 12+; iOS has no Material You, so use a static scheme

Adaptive UI

currentWindowAdaptiveInfoV2(), window size classes, list-detail scaffold

Foldable posture and device-fold APIs are Android-centric; desktop and web resize freely

Popups and dialogs

Popup, Dialog, PopupProperties, DialogProperties

Back handling (Android back button, desktop Esc), iOS safe-area clipping, desktop DialogWindow

Drag and drop

dragAndDropSource, dragAndDropTarget (Experimental)

The transferred data is wrapped in a platform type (ClipData, UIDragItem, AWT Transferable, DataTransfer)

Accessibility

semantics, contentDescription, role, stateDescription, testTag

Screen reader (TalkBack, VoiceOver, macOS VoiceOver, JAWS/NVDA, browser readers) and contrast settings

Images and icons

painterResource, Icon, Image, vector drawables, Material 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

clickable

Click with ripple and semantics role

On desktop and web it also reacts to mouse hover and the keyboard (Enter/Space) when focused

hoverable, pointerHoverIcon

Observe pointer hover and set the cursor

Meaningful on desktop and web with a mouse; touch devices never hover

onKeyEvent, onPreviewKeyEvent

Handle hardware keyboard events

Needed for shortcuts on desktop; iOS only with an external keyboard

windowInsetsPadding, safeDrawingPadding

Apply system insets as padding

Android and iOS report real insets; desktop and web report zero

scrollable, verticalScroll

Drag to scroll with fling

Fling physics and overscroll differ per platform (rubber-banding on iOS)

dragAndDropSource/Target

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

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

MaterialTheme

Stable

Production apps; works with every Material 3 version

MaterialExpressiveTheme

Experimental (alpha Material 3 build, opt-in required)

Prototypes and design experiments that follow Material 3 Expressive

Custom design system over foundation

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

adaptive (currentWindowAdaptiveInfoV2)

Window size class and posture information

Follows the adaptive library line; check the release notes

adaptive-layout (ListDetailPaneScaffold, SupportingPaneScaffold)

Panes shown one, two or three at a time, with a navigator

Material 3 adaptive APIs; several remain annotated experimental, so expect opt-ins

adaptive-navigation (NavigationSuiteScaffold)

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; ComposeViewport reports its size changes, so layouts follow the tab.

  • Android: configuration changes are handled by Compose; do not also react to onConfigurationChanged for 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 PopupPositionProvider anchored to a component

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

DialogProperties

PopupProperties

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")
                }
            }
        }
    }
}

PopupProperties and DialogProperties

Property Meaning Default

PopupProperties.focusable

Whether the popup receives key events

false

PopupProperties.dismissOnBackPress

Dismiss on Android back or the Esc key on desktop; requires focusable = true

true

PopupProperties.dismissOnClickOutside

Dismiss when the user presses outside the popup

true

PopupProperties.usePlatformInsets

iOS-only: clamp the content to the platform insets (safe area)

platform-specific

DialogProperties.dismissOnBackPress / dismissOnClickOutside

Same intent for dialogs

true

DialogProperties.usePlatformDefaultWidth

Use the platform dialog width instead of full content width

true

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.

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

ClipData inside DragAndDropTransferData

iOS

UIDragItem list

Desktop

AWT Transferable (for example StringSelection)

Web

DataTransfer object

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

contentDescription

Text a screen reader announces for non-textual or ambiguous elements

A favorite IconButton described as "Add Ridge Loop to favorites"

role

Functional type: Role.Button, Checkbox, Switch, Image

A custom clickable card exposed as a button

stateDescription

The current state of an interactive element

"Favorite" or "Not favorite"

testTag

Stable identifier for UI tests and automation

"trail_card_42"

traversalIndex / isTraversalGroup

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 = null on 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 Box without role, 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

painterResource(Res.drawable.trail_placeholder)

Vector (XML) and bitmap (PNG, JPEG, WebP) in composeResources/drawable; details in the resources page

Material icons

Icons.Default.Favorite (artifact material-icons-core)

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 (coil-compose and the Ktor network module)

Not part of CMP itself; covers Trail.photoUrl

// 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 Dialog where a non-modal Popup is meant, or the reverse.

  • Reading the width class once with remember, so layouts do not update when the window resizes; call currentWindowAdaptiveInfoV2() in composition instead.

  • Mixing Material 3 and CMP versions without checking the release notes mapping.

  • Adopting MaterialExpressiveTheme or drag and drop in a long-lived app without noting they are Experimental.

  • Omitting jdk.accessibility from 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.